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
@@ -0,0 +1,374 @@
|
||||
/** The Game Boy Color's four sound channels, as the sound engine's register
|
||||
* writes drive them: two pulse channels (the first with a frequency
|
||||
* sweep), the wave channel and the noise channel, their envelopes and
|
||||
* length counters on the 512 Hz frame sequencer, and the NR50/NR51 mixer.
|
||||
* Each output sample averages the channels over its span of CPU cycles,
|
||||
* which keeps high notes and noise from aliasing. No ROM data or CPU. */
|
||||
|
||||
export const CPU_HZ = 4194304;
|
||||
/** Cycles per video frame: the engine updates once per frame. */
|
||||
export const FRAME_CYCLES = 70224;
|
||||
|
||||
const DUTY = [
|
||||
[0, 0, 0, 0, 0, 0, 0, 1],
|
||||
[1, 0, 0, 0, 0, 0, 0, 1],
|
||||
[1, 0, 0, 0, 0, 1, 1, 1],
|
||||
[0, 1, 1, 1, 1, 1, 1, 0],
|
||||
];
|
||||
const NOISE_DIVISORS = [8, 16, 32, 48, 64, 80, 96, 112];
|
||||
|
||||
class Envelope {
|
||||
initial = 0;
|
||||
increase = false;
|
||||
period = 0;
|
||||
volume = 0;
|
||||
timer = 0;
|
||||
write(value: number) {
|
||||
this.initial = value >> 4;
|
||||
this.increase = (value & 8) !== 0;
|
||||
this.period = value & 7;
|
||||
}
|
||||
get dac() {
|
||||
return this.initial !== 0 || this.increase;
|
||||
}
|
||||
trigger() {
|
||||
this.volume = this.initial;
|
||||
this.timer = this.period || 8;
|
||||
}
|
||||
clock() {
|
||||
if (!this.period) return;
|
||||
if (--this.timer > 0) return;
|
||||
this.timer = this.period;
|
||||
if (this.increase && this.volume < 15) this.volume++;
|
||||
else if (!this.increase && this.volume > 0) this.volume--;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Channel {
|
||||
enabled = false;
|
||||
length = 0;
|
||||
lengthEnabled = false;
|
||||
timer = 0;
|
||||
abstract get dac(): boolean;
|
||||
abstract get maxLength(): number;
|
||||
abstract period(): number;
|
||||
/** The channel's digital output, 0-15. */
|
||||
abstract level(): number;
|
||||
abstract step(): void;
|
||||
clockLength() {
|
||||
if (this.lengthEnabled && this.length > 0 && --this.length === 0) this.enabled = false;
|
||||
}
|
||||
trigger() {
|
||||
this.enabled = this.dac;
|
||||
if (!this.length) this.length = this.maxLength;
|
||||
this.timer = this.period();
|
||||
}
|
||||
/** Average analog output (-1..1) over the next `cycles`. */
|
||||
render(cycles: number) {
|
||||
if (!this.dac) return 0;
|
||||
if (!this.enabled) return -1;
|
||||
let sum = 0;
|
||||
let left = cycles;
|
||||
while (left > 0) {
|
||||
const span = Math.min(left, this.timer);
|
||||
sum += span * this.level();
|
||||
left -= span;
|
||||
this.timer -= span;
|
||||
if (this.timer <= 0) {
|
||||
this.timer += this.period();
|
||||
this.step();
|
||||
}
|
||||
}
|
||||
return sum / cycles / 7.5 - 1;
|
||||
}
|
||||
}
|
||||
|
||||
class Pulse extends Channel {
|
||||
readonly envelope = new Envelope();
|
||||
duty = 0;
|
||||
position = 0;
|
||||
frequency = 0;
|
||||
// The first channel's sweep.
|
||||
sweepPeriod = 0;
|
||||
sweepNegate = false;
|
||||
sweepShift = 0;
|
||||
sweepTimer = 0;
|
||||
sweepEnabled = false;
|
||||
shadow = 0;
|
||||
get dac() {
|
||||
return this.envelope.dac;
|
||||
}
|
||||
get maxLength() {
|
||||
return 64;
|
||||
}
|
||||
period() {
|
||||
return (2048 - this.frequency) * 4;
|
||||
}
|
||||
level() {
|
||||
return DUTY[this.duty][this.position] ? this.envelope.volume : 0;
|
||||
}
|
||||
step() {
|
||||
this.position = (this.position + 1) & 7;
|
||||
}
|
||||
override trigger() {
|
||||
super.trigger();
|
||||
this.envelope.trigger();
|
||||
this.shadow = this.frequency;
|
||||
this.sweepTimer = this.sweepPeriod || 8;
|
||||
this.sweepEnabled = this.sweepPeriod !== 0 || this.sweepShift !== 0;
|
||||
if (this.sweepShift) this.sweepCalculate();
|
||||
}
|
||||
private sweepCalculate() {
|
||||
const delta = this.shadow >> this.sweepShift;
|
||||
const next = this.sweepNegate ? this.shadow - delta : this.shadow + delta;
|
||||
if (next > 2047) this.enabled = false;
|
||||
return next;
|
||||
}
|
||||
clockSweep() {
|
||||
if (--this.sweepTimer > 0) return;
|
||||
this.sweepTimer = this.sweepPeriod || 8;
|
||||
if (!this.sweepEnabled || !this.sweepPeriod) return;
|
||||
const next = this.sweepCalculate();
|
||||
if (next <= 2047 && this.sweepShift) {
|
||||
this.shadow = next;
|
||||
this.frequency = next;
|
||||
this.sweepCalculate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Wave extends Channel {
|
||||
on = false;
|
||||
shift = 4;
|
||||
frequency = 0;
|
||||
position = 0;
|
||||
readonly ram = new Uint8Array(16);
|
||||
get dac() {
|
||||
return this.on;
|
||||
}
|
||||
get maxLength() {
|
||||
return 256;
|
||||
}
|
||||
period() {
|
||||
return (2048 - this.frequency) * 2;
|
||||
}
|
||||
level() {
|
||||
const byte = this.ram[this.position >> 1];
|
||||
const sample = this.position & 1 ? byte & 0xf : byte >> 4;
|
||||
return sample >> this.shift;
|
||||
}
|
||||
step() {
|
||||
this.position = (this.position + 1) & 31;
|
||||
}
|
||||
override trigger() {
|
||||
super.trigger();
|
||||
this.position = 0;
|
||||
}
|
||||
}
|
||||
|
||||
class Noise extends Channel {
|
||||
readonly envelope = new Envelope();
|
||||
divisor = 0;
|
||||
shift = 0;
|
||||
narrow = false;
|
||||
lfsr = 0x7fff;
|
||||
get dac() {
|
||||
return this.envelope.dac;
|
||||
}
|
||||
get maxLength() {
|
||||
return 64;
|
||||
}
|
||||
period() {
|
||||
return NOISE_DIVISORS[this.divisor] << this.shift;
|
||||
}
|
||||
level() {
|
||||
return this.lfsr & 1 ? 0 : this.envelope.volume;
|
||||
}
|
||||
step() {
|
||||
const feedback = (this.lfsr ^ (this.lfsr >> 1)) & 1;
|
||||
this.lfsr = (this.lfsr >> 1) | (feedback << 14);
|
||||
if (this.narrow) this.lfsr = (this.lfsr & ~0x40) | (feedback << 6);
|
||||
}
|
||||
override trigger() {
|
||||
super.trigger();
|
||||
this.envelope.trigger();
|
||||
this.lfsr = 0x7fff;
|
||||
}
|
||||
}
|
||||
|
||||
export class Apu {
|
||||
private readonly pulse1 = new Pulse();
|
||||
private readonly pulse2 = new Pulse();
|
||||
private readonly wave = new Wave();
|
||||
private readonly noise = new Noise();
|
||||
private readonly channels: Channel[] = [this.pulse1, this.pulse2, this.wave, this.noise];
|
||||
private nr50 = 0;
|
||||
private nr51 = 0;
|
||||
private sequencer = 0;
|
||||
private sequencerStep = 0;
|
||||
/** Fractional cycles carried between samples. */
|
||||
private carry = 0;
|
||||
/** High-pass filter state per side (the console's output capacitor). */
|
||||
private capacitor = [0, 0];
|
||||
private readonly charge: number;
|
||||
readonly cyclesPerSample: number;
|
||||
/** Master gain, 0-1. */
|
||||
gain = 1;
|
||||
|
||||
constructor(readonly sampleRate: number) {
|
||||
this.cyclesPerSample = CPU_HZ / sampleRate;
|
||||
this.charge = Math.pow(0.999958, this.cyclesPerSample);
|
||||
}
|
||||
|
||||
/** A write to $ff10 + register. */
|
||||
write(register: number, value: number) {
|
||||
const [p1, p2, wave, noise] = [this.pulse1, this.pulse2, this.wave, this.noise];
|
||||
switch (register) {
|
||||
case 0x00:
|
||||
p1.sweepPeriod = (value >> 4) & 7;
|
||||
p1.sweepNegate = (value & 8) !== 0;
|
||||
p1.sweepShift = value & 7;
|
||||
break;
|
||||
case 0x01:
|
||||
case 0x06: {
|
||||
const pulse = register === 0x01 ? p1 : p2;
|
||||
pulse.duty = value >> 6;
|
||||
pulse.length = 64 - (value & 0x3f);
|
||||
break;
|
||||
}
|
||||
case 0x02:
|
||||
case 0x07: {
|
||||
const pulse = register === 0x02 ? p1 : p2;
|
||||
pulse.envelope.write(value);
|
||||
if (!pulse.dac) pulse.enabled = false;
|
||||
break;
|
||||
}
|
||||
case 0x03:
|
||||
case 0x08: {
|
||||
const pulse = register === 0x03 ? p1 : p2;
|
||||
pulse.frequency = (pulse.frequency & 0x700) | value;
|
||||
break;
|
||||
}
|
||||
case 0x04:
|
||||
case 0x09: {
|
||||
const pulse = register === 0x04 ? p1 : p2;
|
||||
pulse.frequency = (pulse.frequency & 0xff) | ((value & 7) << 8);
|
||||
pulse.lengthEnabled = (value & 0x40) !== 0;
|
||||
if (value & 0x80) pulse.trigger();
|
||||
break;
|
||||
}
|
||||
case 0x0a:
|
||||
wave.on = (value & 0x80) !== 0;
|
||||
if (!wave.on) wave.enabled = false;
|
||||
break;
|
||||
case 0x0b:
|
||||
wave.length = 256 - value;
|
||||
break;
|
||||
case 0x0c:
|
||||
wave.shift = [4, 0, 1, 2][(value >> 5) & 3];
|
||||
break;
|
||||
case 0x0d:
|
||||
wave.frequency = (wave.frequency & 0x700) | value;
|
||||
break;
|
||||
case 0x0e:
|
||||
wave.frequency = (wave.frequency & 0xff) | ((value & 7) << 8);
|
||||
wave.lengthEnabled = (value & 0x40) !== 0;
|
||||
if (value & 0x80) wave.trigger();
|
||||
break;
|
||||
case 0x10:
|
||||
noise.length = 64 - (value & 0x3f);
|
||||
break;
|
||||
case 0x11:
|
||||
noise.envelope.write(value);
|
||||
if (!noise.dac) noise.enabled = false;
|
||||
break;
|
||||
case 0x12:
|
||||
noise.shift = value >> 4;
|
||||
noise.narrow = (value & 8) !== 0;
|
||||
noise.divisor = value & 7;
|
||||
break;
|
||||
case 0x13:
|
||||
noise.lengthEnabled = (value & 0x40) !== 0;
|
||||
if (value & 0x80) noise.trigger();
|
||||
break;
|
||||
case 0x14:
|
||||
this.nr50 = value;
|
||||
break;
|
||||
case 0x15:
|
||||
this.nr51 = value;
|
||||
break;
|
||||
case 0x16:
|
||||
if (!(value & 0x80)) this.reset();
|
||||
break;
|
||||
default:
|
||||
if (register >= 0x20 && register < 0x30) wave.ram[register - 0x20] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Power off: every channel and register cleared. */
|
||||
reset() {
|
||||
for (const channel of this.channels) {
|
||||
channel.enabled = false;
|
||||
channel.length = 0;
|
||||
channel.lengthEnabled = false;
|
||||
}
|
||||
this.pulse1.envelope.write(0);
|
||||
this.pulse2.envelope.write(0);
|
||||
this.noise.envelope.write(0);
|
||||
this.wave.on = false;
|
||||
this.nr50 = 0;
|
||||
this.nr51 = 0;
|
||||
}
|
||||
|
||||
/** Whether a channel (0-3) is sounding, as NR52's low bits report. */
|
||||
active(channel: number) {
|
||||
return this.channels[channel].enabled;
|
||||
}
|
||||
|
||||
private clockSequencer() {
|
||||
const step = this.sequencerStep;
|
||||
this.sequencerStep = (step + 1) & 7;
|
||||
if (!(step & 1)) for (const channel of this.channels) channel.clockLength();
|
||||
if (step === 2 || step === 6) this.pulse1.clockSweep();
|
||||
if (step === 7) {
|
||||
this.pulse1.envelope.clock();
|
||||
this.pulse2.envelope.clock();
|
||||
this.noise.envelope.clock();
|
||||
}
|
||||
}
|
||||
|
||||
/** Fill `count` samples of both sides from `offset`. */
|
||||
render(left: Float32Array, right: Float32Array, offset: number, count: number) {
|
||||
const leftVolume = (((this.nr50 >> 4) & 7) + 1) / 8;
|
||||
const rightVolume = ((this.nr50 & 7) + 1) / 8;
|
||||
for (let i = offset; i < offset + count; i++) {
|
||||
this.carry += this.cyclesPerSample;
|
||||
const cycles = Math.floor(this.carry);
|
||||
this.carry -= cycles;
|
||||
if (cycles <= 0) {
|
||||
left[i] = right[i] = 0;
|
||||
continue;
|
||||
}
|
||||
this.sequencer += cycles;
|
||||
while (this.sequencer >= 8192) {
|
||||
this.sequencer -= 8192;
|
||||
this.clockSequencer();
|
||||
}
|
||||
let l = 0,
|
||||
r = 0;
|
||||
for (let n = 0; n < 4; n++) {
|
||||
const output = this.channels[n].render(cycles);
|
||||
if (this.nr51 & (0x10 << n)) l += output;
|
||||
if (this.nr51 & (1 << n)) r += output;
|
||||
}
|
||||
left[i] = this.filter(0, (l / 4) * leftVolume) * this.gain;
|
||||
right[i] = this.filter(1, (r / 4) * rightVolume) * this.gain;
|
||||
}
|
||||
}
|
||||
|
||||
private filter(side: number, input: number) {
|
||||
const output = input - this.capacitor[side];
|
||||
this.capacitor[side] = input - output * this.charge;
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { SOUND } from "./constants";
|
||||
|
||||
/** Trainer classes and ids PlayBattleMusic and PlayVictoryMusic name
|
||||
* (constants/trainer_constants.asm), verified by `npm run check:reference`. */
|
||||
export const MUSIC_TRAINERS = {
|
||||
FALKNER: 1,
|
||||
WHITNEY: 2,
|
||||
BUGSY: 3,
|
||||
MORTY: 4,
|
||||
PRYCE: 5,
|
||||
JASMINE: 6,
|
||||
CHUCK: 7,
|
||||
CLAIR: 8,
|
||||
RIVAL1: 9,
|
||||
WILL: 11,
|
||||
BRUNO: 13,
|
||||
KAREN: 14,
|
||||
KOGA: 15,
|
||||
CHAMPION: 16,
|
||||
BROCK: 17,
|
||||
MISTY: 18,
|
||||
LT_SURGE: 19,
|
||||
ERIKA: 21,
|
||||
JANINE: 26,
|
||||
GRUNTM: 31,
|
||||
SABRINA: 35,
|
||||
RIVAL2: 42,
|
||||
BLAINE: 46,
|
||||
RED: 63,
|
||||
BLUE: 64,
|
||||
GRUNTF: 66,
|
||||
RIVAL2_2_CHIKORITA: 4,
|
||||
BATTLETYPE_ROAMING: 5,
|
||||
BATTLETYPE_SUICUNE: 12,
|
||||
KANTO_LANDMARK: 47,
|
||||
LANDMARK_VICTORY_ROAD: 88,
|
||||
LANDMARK_FAST_SHIP: 95,
|
||||
} as const;
|
||||
const T = MUSIC_TRAINERS;
|
||||
|
||||
/** data/trainers/leaders.asm: KantoGymLeaders, and IsGymLeader's list,
|
||||
* which runs on into it up to the one -1. */
|
||||
const KANTO_GYM_LEADERS: readonly number[] = [
|
||||
T.BROCK, T.MISTY, T.LT_SURGE, T.ERIKA, T.JANINE, T.SABRINA, T.BLAINE, T.BLUE,
|
||||
];
|
||||
const GYM_LEADERS: readonly number[] = [
|
||||
T.FALKNER, T.WHITNEY, T.BUGSY, T.MORTY, T.PRYCE, T.JASMINE, T.CHUCK, T.CLAIR,
|
||||
T.WILL, T.BRUNO, T.KAREN, T.KOGA, T.CHAMPION, T.RED, ...KANTO_GYM_LEADERS,
|
||||
];
|
||||
|
||||
/** RegionCheck for a landmark: Kanto's, except the S.S. Aqua and the
|
||||
* Victory Road area. */
|
||||
export const inKanto = (landmark: number) =>
|
||||
landmark !== T.LANDMARK_FAST_SHIP &&
|
||||
landmark >= T.KANTO_LANDMARK &&
|
||||
landmark < T.LANDMARK_VICTORY_ROAD;
|
||||
|
||||
/** PlayBattleMusic's song. */
|
||||
export function battleMusic(battle: {
|
||||
battleType?: number;
|
||||
trainerClass?: number;
|
||||
trainerId?: number;
|
||||
night: boolean;
|
||||
kanto: boolean;
|
||||
}) {
|
||||
if (battle.battleType === T.BATTLETYPE_SUICUNE || battle.battleType === T.BATTLETYPE_ROAMING)
|
||||
return SOUND.MUSIC_SUICUNE_BATTLE;
|
||||
const trainerClass = battle.trainerClass ?? 0;
|
||||
if (!trainerClass) {
|
||||
if (battle.kanto) return SOUND.MUSIC_KANTO_WILD_BATTLE;
|
||||
return battle.night ? SOUND.MUSIC_JOHTO_WILD_BATTLE_NIGHT : SOUND.MUSIC_JOHTO_WILD_BATTLE;
|
||||
}
|
||||
if (trainerClass === T.CHAMPION || trainerClass === T.RED) return SOUND.MUSIC_CHAMPION_BATTLE;
|
||||
// Executives and Scientists keep the trainer music, as in the original.
|
||||
if (trainerClass === T.GRUNTM || trainerClass === T.GRUNTF) return SOUND.MUSIC_ROCKET_BATTLE;
|
||||
if (KANTO_GYM_LEADERS.includes(trainerClass)) return SOUND.MUSIC_KANTO_GYM_LEADER_BATTLE;
|
||||
if (GYM_LEADERS.includes(trainerClass)) return SOUND.MUSIC_JOHTO_GYM_LEADER_BATTLE;
|
||||
if (trainerClass === T.RIVAL1) return SOUND.MUSIC_RIVAL_BATTLE;
|
||||
if (trainerClass === T.RIVAL2)
|
||||
return (battle.trainerId ?? 0) < T.RIVAL2_2_CHIKORITA
|
||||
? SOUND.MUSIC_RIVAL_BATTLE
|
||||
: SOUND.MUSIC_CHAMPION_BATTLE;
|
||||
return battle.kanto ? SOUND.MUSIC_KANTO_TRAINER_BATTLE : SOUND.MUSIC_JOHTO_TRAINER_BATTLE;
|
||||
}
|
||||
|
||||
/** PlayVictoryMusic's song: a gym leader's, a trainer's or the wild one. */
|
||||
export function victoryMusic(trainerClass: number | undefined) {
|
||||
if (!trainerClass) return SOUND.MUSIC_WILD_VICTORY;
|
||||
return GYM_LEADERS.includes(trainerClass) ? SOUND.MUSIC_GYM_VICTORY : SOUND.MUSIC_TRAINER_VICTORY;
|
||||
}
|
||||
|
||||
/** ComputeHPBarPixels: the HP bar's length out of 48 pixels, at least 1
|
||||
* while any HP is left. Bars under 10 pixels are red (GetHPPal). */
|
||||
export function hpBarPixels(hp: number, maxHp: number) {
|
||||
if (!hp) return 0;
|
||||
let dividend = hp * 48,
|
||||
divisor = maxHp;
|
||||
if (maxHp > 0xff) {
|
||||
// hDivisor is one byte: both sides are divided by 4 first.
|
||||
divisor = (maxHp >> 2) & 0xff;
|
||||
dividend = (dividend & 0xffff) >> 2;
|
||||
}
|
||||
const quotient = divisor ? Math.floor(dividend / divisor) & 0xff : 0;
|
||||
return quotient || 1;
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/** Music and sound effect ids (constants/music_constants.asm and
|
||||
* sfx_constants.asm), verified by name by `npm run check:reference`. */
|
||||
export const SOUND = {
|
||||
MUSIC_NONE: 0x00,
|
||||
MUSIC_TITLE: 0x01,
|
||||
MUSIC_ROUTE_1: 0x02,
|
||||
MUSIC_ROUTE_3: 0x03,
|
||||
MUSIC_ROUTE_12: 0x04,
|
||||
MUSIC_MAGNET_TRAIN: 0x05,
|
||||
MUSIC_KANTO_GYM_LEADER_BATTLE: 0x06,
|
||||
MUSIC_KANTO_TRAINER_BATTLE: 0x07,
|
||||
MUSIC_KANTO_WILD_BATTLE: 0x08,
|
||||
MUSIC_POKEMON_CENTER: 0x09,
|
||||
MUSIC_HIKER_ENCOUNTER: 0x0a,
|
||||
MUSIC_LASS_ENCOUNTER: 0x0b,
|
||||
MUSIC_OFFICER_ENCOUNTER: 0x0c,
|
||||
MUSIC_HEAL: 0x0d,
|
||||
MUSIC_LAVENDER_TOWN: 0x0e,
|
||||
MUSIC_ROUTE_2: 0x0f,
|
||||
MUSIC_MT_MOON: 0x10,
|
||||
MUSIC_SHOW_ME_AROUND: 0x11,
|
||||
MUSIC_GAME_CORNER: 0x12,
|
||||
MUSIC_BICYCLE: 0x13,
|
||||
MUSIC_HALL_OF_FAME: 0x14,
|
||||
MUSIC_VIRIDIAN_CITY: 0x15,
|
||||
MUSIC_CELADON_CITY: 0x16,
|
||||
MUSIC_TRAINER_VICTORY: 0x17,
|
||||
MUSIC_WILD_VICTORY: 0x18,
|
||||
MUSIC_GYM_VICTORY: 0x19,
|
||||
MUSIC_MT_MOON_SQUARE: 0x1a,
|
||||
MUSIC_GYM: 0x1b,
|
||||
MUSIC_PALLET_TOWN: 0x1c,
|
||||
MUSIC_POKEMON_TALK: 0x1d,
|
||||
MUSIC_PROF_OAK: 0x1e,
|
||||
MUSIC_RIVAL_ENCOUNTER: 0x1f,
|
||||
MUSIC_RIVAL_AFTER: 0x20,
|
||||
MUSIC_SURF: 0x21,
|
||||
MUSIC_EVOLUTION: 0x22,
|
||||
MUSIC_NATIONAL_PARK: 0x23,
|
||||
MUSIC_CREDITS: 0x24,
|
||||
MUSIC_AZALEA_TOWN: 0x25,
|
||||
MUSIC_CHERRYGROVE_CITY: 0x26,
|
||||
MUSIC_KIMONO_ENCOUNTER: 0x27,
|
||||
MUSIC_UNION_CAVE: 0x28,
|
||||
MUSIC_JOHTO_WILD_BATTLE: 0x29,
|
||||
MUSIC_JOHTO_TRAINER_BATTLE: 0x2a,
|
||||
MUSIC_ROUTE_30: 0x2b,
|
||||
MUSIC_ECRUTEAK_CITY: 0x2c,
|
||||
MUSIC_VIOLET_CITY: 0x2d,
|
||||
MUSIC_JOHTO_GYM_LEADER_BATTLE: 0x2e,
|
||||
MUSIC_CHAMPION_BATTLE: 0x2f,
|
||||
MUSIC_RIVAL_BATTLE: 0x30,
|
||||
MUSIC_ROCKET_BATTLE: 0x31,
|
||||
MUSIC_PROF_ELM: 0x32,
|
||||
MUSIC_DARK_CAVE: 0x33,
|
||||
MUSIC_ROUTE_29: 0x34,
|
||||
MUSIC_ROUTE_36: 0x35,
|
||||
MUSIC_SS_AQUA: 0x36,
|
||||
MUSIC_YOUNGSTER_ENCOUNTER: 0x37,
|
||||
MUSIC_BEAUTY_ENCOUNTER: 0x38,
|
||||
MUSIC_ROCKET_ENCOUNTER: 0x39,
|
||||
MUSIC_POKEMANIAC_ENCOUNTER: 0x3a,
|
||||
MUSIC_SAGE_ENCOUNTER: 0x3b,
|
||||
MUSIC_NEW_BARK_TOWN: 0x3c,
|
||||
MUSIC_GOLDENROD_CITY: 0x3d,
|
||||
MUSIC_VERMILION_CITY: 0x3e,
|
||||
MUSIC_POKEMON_CHANNEL: 0x3f,
|
||||
MUSIC_POKE_FLUTE_CHANNEL: 0x40,
|
||||
MUSIC_TIN_TOWER: 0x41,
|
||||
MUSIC_SPROUT_TOWER: 0x42,
|
||||
MUSIC_BURNED_TOWER: 0x43,
|
||||
MUSIC_LIGHTHOUSE: 0x44,
|
||||
MUSIC_LAKE_OF_RAGE: 0x45,
|
||||
MUSIC_INDIGO_PLATEAU: 0x46,
|
||||
MUSIC_ROUTE_37: 0x47,
|
||||
MUSIC_ROCKET_HIDEOUT: 0x48,
|
||||
MUSIC_DRAGONS_DEN: 0x49,
|
||||
MUSIC_JOHTO_WILD_BATTLE_NIGHT: 0x4a,
|
||||
MUSIC_RUINS_OF_ALPH_RADIO: 0x4b,
|
||||
MUSIC_CAPTURE: 0x4c,
|
||||
MUSIC_ROUTE_26: 0x4d,
|
||||
MUSIC_MOM: 0x4e,
|
||||
MUSIC_VICTORY_ROAD: 0x4f,
|
||||
MUSIC_POKEMON_LULLABY: 0x50,
|
||||
MUSIC_POKEMON_MARCH: 0x51,
|
||||
MUSIC_GS_OPENING: 0x52,
|
||||
MUSIC_GS_OPENING_2: 0x53,
|
||||
MUSIC_MAIN_MENU: 0x54,
|
||||
MUSIC_RUINS_OF_ALPH_INTERIOR: 0x55,
|
||||
MUSIC_ROCKET_OVERTURE: 0x56,
|
||||
MUSIC_DANCING_HALL: 0x57,
|
||||
MUSIC_BUG_CATCHING_CONTEST_RANKING: 0x58,
|
||||
MUSIC_BUG_CATCHING_CONTEST: 0x59,
|
||||
MUSIC_LAKE_OF_RAGE_ROCKET_RADIO: 0x5a,
|
||||
MUSIC_PRINTER: 0x5b,
|
||||
MUSIC_POST_CREDITS: 0x5c,
|
||||
MUSIC_CLAIR: 0x5d,
|
||||
MUSIC_MOBILE_ADAPTER_MENU: 0x5e,
|
||||
MUSIC_MOBILE_ADAPTER: 0x5f,
|
||||
MUSIC_BUENAS_PASSWORD: 0x60,
|
||||
MUSIC_MYSTICALMAN_ENCOUNTER: 0x61,
|
||||
MUSIC_CRYSTAL_OPENING: 0x62,
|
||||
MUSIC_BATTLE_TOWER_THEME: 0x63,
|
||||
MUSIC_SUICUNE_BATTLE: 0x64,
|
||||
MUSIC_BATTLE_TOWER_LOBBY: 0x65,
|
||||
MUSIC_MOBILE_CENTER: 0x66,
|
||||
SFX_DEX_FANFARE_50_79: 0x00,
|
||||
SFX_ITEM: 0x01,
|
||||
SFX_CAUGHT_MON: 0x02,
|
||||
SFX_POKEBALLS_PLACED_ON_TABLE: 0x03,
|
||||
SFX_POTION: 0x04,
|
||||
SFX_FULL_HEAL: 0x05,
|
||||
SFX_MENU: 0x06,
|
||||
SFX_READ_TEXT: 0x07,
|
||||
SFX_READ_TEXT_2: 0x08,
|
||||
SFX_DEX_FANFARE_20_49: 0x09,
|
||||
SFX_DEX_FANFARE_80_109: 0x0a,
|
||||
SFX_POISON: 0x0b,
|
||||
SFX_GOT_SAFARI_BALLS: 0x0c,
|
||||
SFX_BOOT_PC: 0x0d,
|
||||
SFX_SHUT_DOWN_PC: 0x0e,
|
||||
SFX_CHOOSE_PC_OPTION: 0x0f,
|
||||
SFX_ESCAPE_ROPE: 0x10,
|
||||
SFX_PUSH_BUTTON: 0x11,
|
||||
SFX_SECOND_PART_OF_ITEMFINDER: 0x12,
|
||||
SFX_WARP_TO: 0x13,
|
||||
SFX_WARP_FROM: 0x14,
|
||||
SFX_CHANGE_DEX_MODE: 0x15,
|
||||
SFX_JUMP_OVER_LEDGE: 0x16,
|
||||
SFX_GRASS_RUSTLE: 0x17,
|
||||
SFX_FLY: 0x18,
|
||||
SFX_WRONG: 0x19,
|
||||
SFX_SQUEAK: 0x1a,
|
||||
SFX_STRENGTH: 0x1b,
|
||||
SFX_BOAT: 0x1c,
|
||||
SFX_WALL_OPEN: 0x1d,
|
||||
SFX_PLACE_PUZZLE_PIECE_DOWN: 0x1e,
|
||||
SFX_ENTER_DOOR: 0x1f,
|
||||
SFX_SWITCH_POKEMON: 0x20,
|
||||
SFX_TALLY: 0x21,
|
||||
SFX_TRANSACTION: 0x22,
|
||||
SFX_EXIT_BUILDING: 0x23,
|
||||
SFX_BUMP: 0x24,
|
||||
SFX_SAVE: 0x25,
|
||||
SFX_POKEFLUTE: 0x26,
|
||||
SFX_ELEVATOR_END: 0x27,
|
||||
SFX_THROW_BALL: 0x28,
|
||||
SFX_BALL_POOF: 0x29,
|
||||
SFX_FAINT: 0x2a,
|
||||
SFX_RUN: 0x2b,
|
||||
SFX_SLOT_MACHINE_START: 0x2c,
|
||||
SFX_FANFARE: 0x2d,
|
||||
SFX_PECK: 0x2e,
|
||||
SFX_KINESIS: 0x2f,
|
||||
SFX_LICK: 0x30,
|
||||
SFX_POUND: 0x31,
|
||||
SFX_MOVE_PUZZLE_PIECE: 0x32,
|
||||
SFX_COMET_PUNCH: 0x33,
|
||||
SFX_MEGA_PUNCH: 0x34,
|
||||
SFX_SCRATCH: 0x35,
|
||||
SFX_VICEGRIP: 0x36,
|
||||
SFX_RAZOR_WIND: 0x37,
|
||||
SFX_CUT: 0x38,
|
||||
SFX_WING_ATTACK: 0x39,
|
||||
SFX_WHIRLWIND: 0x3a,
|
||||
SFX_BIND: 0x3b,
|
||||
SFX_VINE_WHIP: 0x3c,
|
||||
SFX_DOUBLE_KICK: 0x3d,
|
||||
SFX_MEGA_KICK: 0x3e,
|
||||
SFX_HEADBUTT: 0x3f,
|
||||
SFX_HORN_ATTACK: 0x40,
|
||||
SFX_TACKLE: 0x41,
|
||||
SFX_POISON_STING: 0x42,
|
||||
SFX_POWDER: 0x43,
|
||||
SFX_DOUBLESLAP: 0x44,
|
||||
SFX_BITE: 0x45,
|
||||
SFX_JUMP_KICK: 0x46,
|
||||
SFX_STOMP: 0x47,
|
||||
SFX_TAIL_WHIP: 0x48,
|
||||
SFX_KARATE_CHOP: 0x49,
|
||||
SFX_SUBMISSION: 0x4a,
|
||||
SFX_WATER_GUN: 0x4b,
|
||||
SFX_SWORDS_DANCE: 0x4c,
|
||||
SFX_THUNDER: 0x4d,
|
||||
SFX_SUPERSONIC: 0x4e,
|
||||
SFX_LEER: 0x4f,
|
||||
SFX_EMBER: 0x50,
|
||||
SFX_BUBBLEBEAM: 0x51,
|
||||
SFX_HYDRO_PUMP: 0x52,
|
||||
SFX_SURF: 0x53,
|
||||
SFX_PSYBEAM: 0x54,
|
||||
SFX_CHARGE: 0x55,
|
||||
SFX_THUNDERSHOCK: 0x56,
|
||||
SFX_PSYCHIC: 0x57,
|
||||
SFX_SCREECH: 0x58,
|
||||
SFX_BONE_CLUB: 0x59,
|
||||
SFX_SHARPEN: 0x5a,
|
||||
SFX_EGG_BOMB: 0x5b,
|
||||
SFX_SING: 0x5c,
|
||||
SFX_HYPER_BEAM: 0x5d,
|
||||
SFX_SHINE: 0x5e,
|
||||
SFX_UNKNOWN_5F: 0x5f,
|
||||
SFX_UNKNOWN_60: 0x60,
|
||||
SFX_UNKNOWN_61: 0x61,
|
||||
SFX_SWITCH_POCKETS: 0x62,
|
||||
SFX_UNKNOWN_63: 0x63,
|
||||
SFX_BURN: 0x64,
|
||||
SFX_TITLE_SCREEN_ENTRANCE: 0x65,
|
||||
SFX_UNKNOWN_66: 0x66,
|
||||
SFX_GET_COIN_FROM_SLOTS: 0x67,
|
||||
SFX_PAY_DAY: 0x68,
|
||||
SFX_METRONOME: 0x69,
|
||||
SFX_CALL: 0x6a,
|
||||
SFX_HANG_UP: 0x6b,
|
||||
SFX_NO_SIGNAL: 0x6c,
|
||||
SFX_SANDSTORM: 0x6d,
|
||||
SFX_ELEVATOR: 0x6e,
|
||||
SFX_PROTECT: 0x6f,
|
||||
SFX_SKETCH: 0x70,
|
||||
SFX_RAIN_DANCE: 0x71,
|
||||
SFX_AEROBLAST: 0x72,
|
||||
SFX_SPARK: 0x73,
|
||||
SFX_CURSE: 0x74,
|
||||
SFX_RAGE: 0x75,
|
||||
SFX_THIEF: 0x76,
|
||||
SFX_THIEF_2: 0x77,
|
||||
SFX_SPIDER_WEB: 0x78,
|
||||
SFX_MIND_READER: 0x79,
|
||||
SFX_NIGHTMARE: 0x7a,
|
||||
SFX_SNORE: 0x7b,
|
||||
SFX_SWEET_KISS: 0x7c,
|
||||
SFX_SWEET_KISS_2: 0x7d,
|
||||
SFX_BELLY_DRUM: 0x7e,
|
||||
SFX_TOXIC: 0x7f,
|
||||
SFX_SLUDGE_BOMB: 0x80,
|
||||
SFX_FORESIGHT: 0x81,
|
||||
SFX_SPITE: 0x82,
|
||||
SFX_OUTRAGE: 0x83,
|
||||
SFX_PERISH_SONG: 0x84,
|
||||
SFX_GIGA_DRAIN: 0x85,
|
||||
SFX_ATTRACT: 0x86,
|
||||
SFX_KINESIS_2: 0x87,
|
||||
SFX_ZAP_CANNON: 0x88,
|
||||
SFX_MEAN_LOOK: 0x89,
|
||||
SFX_HEAL_BELL: 0x8a,
|
||||
SFX_RETURN: 0x8b,
|
||||
SFX_EXP_BAR: 0x8c,
|
||||
SFX_MILK_DRINK: 0x8d,
|
||||
SFX_PRESENT: 0x8e,
|
||||
SFX_MORNING_SUN: 0x8f,
|
||||
SFX_LEVEL_UP: 0x90,
|
||||
SFX_KEY_ITEM: 0x91,
|
||||
SFX_FANFARE_2: 0x92,
|
||||
SFX_REGISTER_PHONE_NUMBER: 0x93,
|
||||
SFX_3RD_PLACE: 0x94,
|
||||
SFX_GET_EGG_UNUSED: 0x95,
|
||||
SFX_GET_EGG: 0x96,
|
||||
SFX_MOVE_DELETED: 0x97,
|
||||
SFX_2ND_PLACE: 0x98,
|
||||
SFX_1ST_PLACE: 0x99,
|
||||
SFX_CHOOSE_A_CARD: 0x9a,
|
||||
SFX_GET_TM: 0x9b,
|
||||
SFX_GET_BADGE: 0x9c,
|
||||
SFX_QUIT_SLOTS: 0x9d,
|
||||
SFX_EGG_CRACK: 0x9e,
|
||||
SFX_DEX_FANFARE_LESS_THAN_20: 0x9f,
|
||||
SFX_DEX_FANFARE_140_169: 0xa0,
|
||||
SFX_DEX_FANFARE_170_199: 0xa1,
|
||||
SFX_DEX_FANFARE_200_229: 0xa2,
|
||||
SFX_DEX_FANFARE_230_PLUS: 0xa3,
|
||||
SFX_EVOLVED: 0xa4,
|
||||
SFX_MASTER_BALL: 0xa5,
|
||||
SFX_EGG_HATCH: 0xa6,
|
||||
SFX_GS_INTRO_CHARIZARD_FIREBALL: 0xa7,
|
||||
SFX_GS_INTRO_POKEMON_APPEARS: 0xa8,
|
||||
SFX_FLASH: 0xa9,
|
||||
SFX_GAME_FREAK_LOGO_GS: 0xaa,
|
||||
SFX_NOT_VERY_EFFECTIVE: 0xab,
|
||||
SFX_DAMAGE: 0xac,
|
||||
SFX_SUPER_EFFECTIVE: 0xad,
|
||||
SFX_BALL_BOUNCE: 0xae,
|
||||
SFX_MOONLIGHT: 0xaf,
|
||||
SFX_ENCORE: 0xb0,
|
||||
SFX_BEAT_UP: 0xb1,
|
||||
SFX_BATON_PASS: 0xb2,
|
||||
SFX_BALL_WOBBLE: 0xb3,
|
||||
SFX_SWEET_SCENT: 0xb4,
|
||||
SFX_SWEET_SCENT_2: 0xb5,
|
||||
SFX_HIT_END_OF_EXP_BAR: 0xb6,
|
||||
SFX_GIVE_TRADEMON: 0xb7,
|
||||
SFX_GET_TRADEMON: 0xb8,
|
||||
SFX_TRAIN_ARRIVED: 0xb9,
|
||||
SFX_STOP_SLOT: 0xba,
|
||||
SFX_2_BOOPS: 0xbb,
|
||||
SFX_GLASS_TING: 0xbc,
|
||||
SFX_GLASS_TING_2: 0xbd,
|
||||
SFX_INTRO_UNOWN_1: 0xbe,
|
||||
SFX_INTRO_UNOWN_2: 0xbf,
|
||||
SFX_INTRO_UNOWN_3: 0xc0,
|
||||
SFX_DITTO_POP_UP: 0xc1,
|
||||
SFX_DITTO_TRANSFORM: 0xc2,
|
||||
SFX_INTRO_SUICUNE_1: 0xc3,
|
||||
SFX_INTRO_PICHU: 0xc4,
|
||||
SFX_INTRO_SUICUNE_2: 0xc5,
|
||||
SFX_INTRO_SUICUNE_3: 0xc6,
|
||||
SFX_DITTO_BOUNCE: 0xc7,
|
||||
SFX_INTRO_SUICUNE_4: 0xc8,
|
||||
SFX_GAME_FREAK_PRESENTS: 0xc9,
|
||||
SFX_TINGLE: 0xca,
|
||||
SFX_INTRO_WHOOSH: 0xcb,
|
||||
SFX_TWO_PC_BEEPS: 0xcc,
|
||||
SFX_4_NOTE_DITTY: 0xcd,
|
||||
SFX_TWINKLE: 0xce,
|
||||
MUSIC_MAHOGANY_MART: 0x64,
|
||||
} as const;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,77 @@
|
||||
import workletUrl from "./worklet.ts?worker&url";
|
||||
import type { AudioMessage } from "./worklet";
|
||||
|
||||
/** The browser side of the sound: an AudioContext running the synthesizer
|
||||
* worklet, fed the engine's register writes frame by frame. Browsers start
|
||||
* audio only from a user gesture, so `unlock` runs from the first press. */
|
||||
export class AudioOutput {
|
||||
private context?: AudioContext;
|
||||
private node?: AudioWorkletNode;
|
||||
private gain = 1;
|
||||
private failed = false;
|
||||
|
||||
/** Create or resume the context; call from a user gesture. */
|
||||
unlock() {
|
||||
if (this.failed) return;
|
||||
try {
|
||||
if (!this.context) {
|
||||
const Context =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!Context || !("audioWorklet" in Context.prototype)) {
|
||||
this.failed = true;
|
||||
return;
|
||||
}
|
||||
const context = new Context({ latencyHint: "interactive" });
|
||||
this.context = context;
|
||||
void context.audioWorklet
|
||||
.addModule(workletUrl)
|
||||
.then(() => {
|
||||
const node = new AudioWorkletNode(context, "crystal-audio", {
|
||||
numberOfInputs: 0,
|
||||
outputChannelCount: [2],
|
||||
});
|
||||
node.connect(context.destination);
|
||||
this.node = node;
|
||||
this.post({ kind: "gain", value: this.gain });
|
||||
})
|
||||
.catch(() => {
|
||||
this.failed = true;
|
||||
});
|
||||
}
|
||||
if (this.context.state !== "running") void this.context.resume().catch(() => {});
|
||||
} catch {
|
||||
this.failed = true;
|
||||
}
|
||||
}
|
||||
/** Whether sound is playing (unlocked, loaded and running). */
|
||||
get running() {
|
||||
return !!this.node && this.context?.state === "running";
|
||||
}
|
||||
/** Frames of register writes, in order. */
|
||||
send(frames: number[][]) {
|
||||
if (!frames.length || !this.running) return;
|
||||
this.post({ kind: "frames", frames });
|
||||
}
|
||||
setGain(value: number) {
|
||||
this.gain = value;
|
||||
this.post({ kind: "gain", value });
|
||||
}
|
||||
/** Pause: silence at once and drop the queued frames. */
|
||||
suspend() {
|
||||
this.post({ kind: "reset" });
|
||||
if (this.context?.state === "running") void this.context.suspend().catch(() => {});
|
||||
}
|
||||
resume() {
|
||||
if (this.context && this.context.state !== "running") void this.context.resume().catch(() => {});
|
||||
}
|
||||
dispose() {
|
||||
this.node?.disconnect();
|
||||
void this.context?.close().catch(() => {});
|
||||
this.node = undefined;
|
||||
this.context = undefined;
|
||||
}
|
||||
private post(message: AudioMessage) {
|
||||
this.node?.port.postMessage(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import { AudioEngine, NR14, NR24, NR34, NR44, NR50, NR51, NR52, WAVE_RAM, packWrite } from "./engine";
|
||||
import { SOUND } from "./constants";
|
||||
import { AUDIO } from "../rom/profile";
|
||||
|
||||
const PLAYER_BIKE = 1;
|
||||
/** Frames of register writes kept for the output; older ones are dropped
|
||||
* when nobody listens (tests, or before the first press unlocks sound). */
|
||||
const MAX_FRAMES = 32;
|
||||
|
||||
/** The game's side of the sound: the engine, run once per video frame as
|
||||
* VBlank's UpdateSound does, and the home/audio.asm routines the game
|
||||
* calls (map music, fades, cries). The register writes of each frame are
|
||||
* kept for the synthesizer. */
|
||||
export class GameSound {
|
||||
readonly engine: AudioEngine;
|
||||
private frames: number[][] = [];
|
||||
|
||||
constructor(private readonly reader: RomReader) {
|
||||
this.engine = new AudioEngine(reader);
|
||||
this.engine.initSound();
|
||||
}
|
||||
|
||||
/** One video frame: UpdateSound. */
|
||||
frame() {
|
||||
this.playQueued();
|
||||
this.engine.updateSound();
|
||||
this.frames.push(this.engine.drain());
|
||||
if (this.frames.length > MAX_FRAMES) this.frames.shift();
|
||||
}
|
||||
/** The frames of register writes since the last call. */
|
||||
takeFrames() {
|
||||
const frames = this.frames;
|
||||
this.frames = [];
|
||||
return frames;
|
||||
}
|
||||
|
||||
/** A frame that restores the sound registers after the output was
|
||||
* reset (a pause): the last values written, each channel retriggered. */
|
||||
snapshot() {
|
||||
const registers = this.engine.registers;
|
||||
const frame: number[] = [packWrite(NR52, 0x80), packWrite(NR50, registers[NR50]), packWrite(NR51, registers[NR51])];
|
||||
for (let i = 0; i < 16; i++) frame.push(packWrite(WAVE_RAM + i, registers[WAVE_RAM + i]));
|
||||
for (let register = 0; register < NR50; register++) {
|
||||
const trigger = register === NR14 || register === NR24 || register === NR34 || register === NR44;
|
||||
frame.push(packWrite(register, registers[register] | (trigger ? 0x80 : 0)));
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
/** PlayMusic */
|
||||
playMusic(id: number) {
|
||||
this.engine.playMusic(id);
|
||||
}
|
||||
/** Script_playmusic and PlayMusic2: stop, wait a frame, then play. */
|
||||
playMusicAfterFrame(id: number) {
|
||||
this.engine.playMusic(SOUND.MUSIC_NONE);
|
||||
this.frame();
|
||||
this.engine.playMusic(id);
|
||||
}
|
||||
/** Script_playmusic: the fade stops and the volume is full. */
|
||||
scriptPlayMusic(id: number) {
|
||||
this.engine.playMusic(SOUND.MUSIC_NONE);
|
||||
this.engine.musicFade = 0;
|
||||
this.engine.maxVolume();
|
||||
this.engine.playMusic(id);
|
||||
}
|
||||
/** PlayTrainerEncounterMusic: the trainer class's encounter music. */
|
||||
trainerEncounterMusic(trainerClass: number) {
|
||||
this.engine.musicFade = 0;
|
||||
this.engine.playMusic(SOUND.MUSIC_NONE);
|
||||
this.frame();
|
||||
this.engine.maxVolume();
|
||||
const entry = AUDIO.TrainerEncounterMusic + (trainerClass & 0xff);
|
||||
this.engine.playMusic(entry < this.reader.bytes.length ? this.reader.bytes[entry] : 0);
|
||||
}
|
||||
/** PlayMonCry2: a cry from both sides, without waiting. */
|
||||
playMonCry2(species: number) {
|
||||
this.engine.setCryTracks(0, 0);
|
||||
this.playCry(species);
|
||||
}
|
||||
/** PlayStereoCry: from the side `tracks` names ($f0 the player's, $0f
|
||||
* the enemy's) when stereo is on. */
|
||||
playStereoCry(species: number, tracks: number) {
|
||||
this.engine.setCryTracks(1, tracks);
|
||||
this.playCry(species);
|
||||
}
|
||||
/** Sound effects to play one after another, each once the one before it
|
||||
* has ended, as PlaySFX and WaitSFX in turn. */
|
||||
queueSFX(ids: readonly number[]) {
|
||||
this.afterSFX(...ids.map((id) => () => this.engine.playSFX(id)));
|
||||
}
|
||||
/** Sounds to start one after another, each once the sound effects (or
|
||||
* cry) before it have ended: a sequence with WaitSFX between its steps
|
||||
* that the game itself does not wait for. */
|
||||
afterSFX(...actions: (() => void)[]) {
|
||||
this.queued.push(...actions);
|
||||
this.playQueued();
|
||||
}
|
||||
private queued: (() => void)[] = [];
|
||||
private playQueued() {
|
||||
// Steps that start no sound effect (music) run on at once.
|
||||
while (this.queued.length && !this.engine.checkSFX()) this.queued.shift()!();
|
||||
}
|
||||
/** Drop the queued steps (a new scene replaces them). */
|
||||
clearQueue() {
|
||||
this.queued = [];
|
||||
}
|
||||
/** PlayBattleMusic: no fade, full volume, a frame of silence first. */
|
||||
playBattleMusic(id: number) {
|
||||
this.engine.musicFade = 0;
|
||||
this.engine.playMusic(SOUND.MUSIC_NONE);
|
||||
this.frame();
|
||||
this.engine.maxVolume();
|
||||
this.engine.playMusic(id);
|
||||
}
|
||||
/** wLowHealthAlarm's DANGER_ON_F, changed only when it differs. */
|
||||
setDanger(on: boolean) {
|
||||
if (on !== this.engine.lowHealthAlarm) this.engine.setLowHealthAlarm(on);
|
||||
}
|
||||
/** PlaySFX */
|
||||
playSFX(id: number) {
|
||||
this.engine.playSFX(id);
|
||||
}
|
||||
/** WaitSFX's condition: whether a sound effect still plays. */
|
||||
get sfxPlaying() {
|
||||
return this.engine.checkSFX();
|
||||
}
|
||||
/** PlayCry for a species. */
|
||||
playCry(species: number) {
|
||||
if (species >= 1 && species <= 251) this.engine.playCry(species);
|
||||
}
|
||||
/** Script_musicfadeout: fade out at `rate`, then play `id`. */
|
||||
fadeOutTo(id: number, rate: number) {
|
||||
this.engine.fadeTo(id, rate & 0x7f);
|
||||
}
|
||||
/** SkipMusic: run the engine `frames` times at once. */
|
||||
skipMusic(frames: number) {
|
||||
for (let n = 0; n < frames; n++) this.engine.updateSound();
|
||||
}
|
||||
|
||||
/** PlayMapMusic: switch to the map's music unless it already plays. */
|
||||
playMapMusic(id: number) {
|
||||
if (this.engine.mapMusic === id) return;
|
||||
this.engine.playMusic(SOUND.MUSIC_NONE);
|
||||
this.frame();
|
||||
this.engine.mapMusic = id;
|
||||
this.engine.playMusic(id);
|
||||
}
|
||||
/** PlayMapMusicBike: the Bicycle's music while riding. */
|
||||
playMapMusicBike(id: number, playerState: number) {
|
||||
this.engine.dontPlayMapMusicOnReload = false;
|
||||
const music = playerState === PLAYER_BIKE ? SOUND.MUSIC_BICYCLE : id;
|
||||
this.engine.playMusic(SOUND.MUSIC_NONE);
|
||||
this.frame();
|
||||
this.engine.mapMusic = music;
|
||||
this.engine.playMusic(music);
|
||||
}
|
||||
/** FadeToMapMusic: fade out to the map's music unless it already plays. */
|
||||
fadeToMapMusic(id: number) {
|
||||
if (this.engine.mapMusic === id) return;
|
||||
this.engine.fadeTo(id, 8);
|
||||
this.engine.mapMusic = id;
|
||||
}
|
||||
/** FadeInToMusic */
|
||||
fadeInToMusic() {
|
||||
this.engine.fadeInToMusic();
|
||||
}
|
||||
/** RestartMapMusic */
|
||||
restartMapMusic() {
|
||||
this.engine.playMusic(SOUND.MUSIC_NONE);
|
||||
this.frame();
|
||||
this.engine.playMusic(this.engine.mapMusic);
|
||||
}
|
||||
/** TryRestartMapMusic: unless a script asked otherwise. */
|
||||
tryRestartMapMusic() {
|
||||
if (!this.engine.dontPlayMapMusicOnReload) return this.restartMapMusic();
|
||||
this.engine.mapMusic = 0;
|
||||
this.engine.playMusic(SOUND.MUSIC_NONE);
|
||||
this.frame();
|
||||
this.engine.dontPlayMapMusicOnReload = false;
|
||||
}
|
||||
/** ForceMapMusic (MAPSETUP_RELOADMAP): the Bicycle fades back in. */
|
||||
forceMapMusic(playerState: number) {
|
||||
if (playerState === PLAYER_BIKE) {
|
||||
this.engine.minVolume();
|
||||
this.engine.musicFade = 0x88;
|
||||
}
|
||||
this.tryRestartMapMusic();
|
||||
}
|
||||
/** Script_dontrestartmapmusic */
|
||||
dontRestartMapMusic() {
|
||||
this.engine.dontPlayMapMusicOnReload = true;
|
||||
}
|
||||
/** wPlayerState, which FadeMusic reads for the Bicycle. */
|
||||
setPlayerState(state: number) {
|
||||
this.engine.playerState = state;
|
||||
}
|
||||
setStereo(stereo: boolean) {
|
||||
this.engine.stereo = stereo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Apu, FRAME_CYCLES } from "./apu";
|
||||
|
||||
/** The audio thread: plays the sound engine's register writes, one video
|
||||
* frame of writes per 70224 cycles of synthesized sound. The game sends
|
||||
* frames as it runs; a few frames of queue absorb its timing jitter. */
|
||||
|
||||
declare const sampleRate: number;
|
||||
declare function registerProcessor(name: string, processor: unknown): void;
|
||||
declare class AudioWorkletProcessor {
|
||||
readonly port: MessagePort;
|
||||
}
|
||||
|
||||
/** Frames queued beyond this are applied at once to catch up. */
|
||||
const MAX_QUEUE = 8;
|
||||
/** After running dry, wait for this many frames before playing again. */
|
||||
const REFILL = 3;
|
||||
|
||||
export type AudioMessage =
|
||||
| { kind: "frames"; frames: number[][] }
|
||||
| { kind: "reset" }
|
||||
| { kind: "gain"; value: number };
|
||||
|
||||
class CrystalAudioProcessor extends AudioWorkletProcessor {
|
||||
private readonly apu = new Apu(sampleRate);
|
||||
private queue: number[][] = [];
|
||||
private cycles = 0;
|
||||
private starving = true;
|
||||
constructor() {
|
||||
super();
|
||||
this.port.onmessage = ({ data }: MessageEvent<AudioMessage>) => {
|
||||
if (data.kind === "frames") this.queue.push(...data.frames);
|
||||
else if (data.kind === "reset") {
|
||||
this.queue = [];
|
||||
this.apu.reset();
|
||||
this.starving = true;
|
||||
} else this.apu.gain = data.value;
|
||||
};
|
||||
}
|
||||
private apply(frame: number[]) {
|
||||
for (const write of frame) this.apu.write(write >> 8, write & 0xff);
|
||||
}
|
||||
process(_inputs: Float32Array[][], outputs: Float32Array[][]) {
|
||||
const [left, right = left] = outputs[0];
|
||||
if (!left) return true;
|
||||
while (this.queue.length > MAX_QUEUE) this.apply(this.queue.shift()!);
|
||||
if (this.starving && this.queue.length >= REFILL) this.starving = false;
|
||||
let offset = 0;
|
||||
while (offset < left.length) {
|
||||
const until = Math.ceil((FRAME_CYCLES - this.cycles) / this.apu.cyclesPerSample);
|
||||
const count = Math.max(1, Math.min(left.length - offset, until));
|
||||
this.apu.render(left, right, offset, count);
|
||||
offset += count;
|
||||
this.cycles += count * this.apu.cyclesPerSample;
|
||||
if (this.cycles >= FRAME_CYCLES) {
|
||||
this.cycles -= FRAME_CYCLES;
|
||||
if (!this.starving) {
|
||||
const frame = this.queue.shift();
|
||||
if (frame) this.apply(frame);
|
||||
else this.starving = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("crystal-audio", CrystalAudioProcessor);
|
||||
@@ -135,6 +135,14 @@ export type BattleEffect =
|
||||
| { kind: "party"; text: string; menu: boolean; cancel: boolean }
|
||||
/** MoveSelectionScreen for an item: a move slot (0-3), or -1 to go back. */
|
||||
| { kind: "move"; text: string; moves: number[]; pp: number[] };
|
||||
/** PlayStereoCry from a side, sound effects one after another (WaitSFX
|
||||
* between them), PlayVictoryMusic or the capture music, and
|
||||
* StopDangerSound. */
|
||||
export type BattleSound =
|
||||
| { cry: number; side: "player" | "enemy" }
|
||||
| { sfx: number[] }
|
||||
| { music: "victory" | "capture" }
|
||||
| { stopDanger: true };
|
||||
/** A name, level and max HP, as DisplayCaughtContestMonStats prints them. */
|
||||
export type ContestMonSummary = { name: string; level: number; maxHp: number };
|
||||
/** The Bug-Catching Contest's wParkBallsRemaining and wContestMon. */
|
||||
@@ -214,9 +222,15 @@ export interface BattleTrainer {
|
||||
}
|
||||
|
||||
type Gen<T = void> = Generator<BattleEffect, T, BattleInput>;
|
||||
// Sound effect ids (constants/sfx_constants.asm; audio/constants.ts).
|
||||
const SFX_KINESIS = 0x2f,
|
||||
SFX_FAINT = 0x2a,
|
||||
SFX_RUN = 0x2b;
|
||||
|
||||
export interface BattleOptions {
|
||||
data: PokemonData;
|
||||
/** Where the battle's sounds go (cries, sound effects, music). */
|
||||
sound?: (sound: BattleSound) => void;
|
||||
party: PartyMon[];
|
||||
pokedex: { seen: Set<number>; caught: Set<number> };
|
||||
playerId: number;
|
||||
@@ -708,12 +722,34 @@ export class Battle {
|
||||
/** StdBattleTextbox / BattleTextbox with a ROM text. `button` waits for
|
||||
* a button afterwards even without a prompt (WaitPressAorB_BlinkCursor). */
|
||||
private *say(address: number, button = false): Gen {
|
||||
const { text, prompt } = readTextEnd(this.reader, address, this.context());
|
||||
const sfx: number[] = [];
|
||||
const { text, prompt } = readTextEnd(this.reader, address, {
|
||||
...this.context(),
|
||||
onSound: (id) => sfx.push(id),
|
||||
});
|
||||
// Sounds of the text itself (a fanfare) and of its text_asm routines.
|
||||
const sounds = this.textSounds.splice(0);
|
||||
if (sfx.length) sounds.push({ sfx });
|
||||
for (const sound of sounds) this.playSound(sound);
|
||||
// The tutorial's auto input presses A at every prompt.
|
||||
const waits = (prompt || button) && this.battleType !== C.BATTLETYPE_TUTORIAL;
|
||||
for (const page of text.split("\n\n").filter(Boolean))
|
||||
yield { kind: "text", text: page, prompt: waits };
|
||||
}
|
||||
/** Sounds text_asm routines play, given out by the text that ran them. */
|
||||
private textSounds: BattleSound[] = [];
|
||||
/** A sound for the presentation; the battle never waits for it. */
|
||||
private playSound(sound: BattleSound) {
|
||||
this.options.sound?.(sound);
|
||||
}
|
||||
/** wBattleLowHealthAlarm: set once the battle is won, which keeps the
|
||||
* danger tone off for the rest of it. */
|
||||
lowHealthAlarmOff = false;
|
||||
/** StopDangerSound, and wBattleLowHealthAlarm for the rest of the battle. */
|
||||
private winSounds() {
|
||||
this.lowHealthAlarmOff = true;
|
||||
this.playSound({ stopDanger: true });
|
||||
}
|
||||
/** text_asm routines that battle texts run between fragments. */
|
||||
private textAsm(offset: number): number {
|
||||
const at = (label: string, fragment = 4) => BATTLE[label as keyof typeof BATTLE] + fragment;
|
||||
@@ -773,7 +809,8 @@ export class Battle {
|
||||
return BATTLE[(text ?? "BattleCommand_Charge.BattleDugText") as keyof typeof BATTLE];
|
||||
}
|
||||
case at("Text_GotchaMonWasCaught"):
|
||||
// Capture music (not ported), then WaitButtonText.
|
||||
// WaitSFX, MUSIC_CAPTURE, then WaitButtonText.
|
||||
this.textSounds.push({ music: "capture" });
|
||||
return BATTLE.WaitButtonText;
|
||||
default:
|
||||
throw new UnsupportedBattle(`Battle text routine at 0x${offset.toString(16)} is not ported.`);
|
||||
@@ -1118,7 +1155,10 @@ export class Battle {
|
||||
if (trainer) {
|
||||
yield* this.say(BATTLE.WantsToBattleText);
|
||||
yield* this.say(BATTLE.BattleText_EnemySentOut);
|
||||
} else
|
||||
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
|
||||
} else {
|
||||
// The wild Pokémon's picture animates with its cry before the text.
|
||||
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
|
||||
yield* this.say(
|
||||
this.battleType === C.BATTLETYPE_FISH
|
||||
? BATTLE.HookedPokemonAttackedText
|
||||
@@ -1128,6 +1168,7 @@ export class Battle {
|
||||
? BATTLE.WildCelebiAppearedText
|
||||
: BATTLE.WildPokemonAppearedText,
|
||||
);
|
||||
}
|
||||
this.resetBattleParticipants();
|
||||
this.initBattleMon();
|
||||
this.checkAmuletCoin();
|
||||
@@ -1141,12 +1182,19 @@ export class Battle {
|
||||
// WinTrainerBattle and LostBattle's .battle_tower: BattleTowerText
|
||||
// instead of the prize money or the whiteout.
|
||||
const won = (this.battleResult & 0x3f) === C.WIN;
|
||||
if (won) yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
|
||||
if (won) {
|
||||
this.winSounds();
|
||||
this.playSound({ music: "victory" });
|
||||
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
|
||||
}
|
||||
const text = won ? trainer.winText : trainer.lossText;
|
||||
if (text) yield* this.say(text);
|
||||
return { result: this.battleResult, evolvable: this.evolvable };
|
||||
}
|
||||
if (trainer && (this.battleResult & 0x3f) === C.WIN) {
|
||||
// WinTrainerBattle: StopDangerSound, PlayVictoryMusic.
|
||||
this.winSounds();
|
||||
this.playSound({ music: "victory" });
|
||||
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
|
||||
if (this.options.canLose) {
|
||||
for (const mon of this.options.party) {
|
||||
@@ -2350,7 +2398,11 @@ export class Battle {
|
||||
// The contest always lets the player go; the event battles never do.
|
||||
if (this.battleType === C.BATTLETYPE_CONTEST) {
|
||||
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
|
||||
yield* this.say(BATTLE.BattleText_GotAwaySafely);
|
||||
this.playSound({ stopDanger: true });
|
||||
this.playSound({ sfx: [SFX_RUN] });
|
||||
this.playSound({ stopDanger: true });
|
||||
this.playSound({ sfx: [SFX_RUN] });
|
||||
yield* this.say(BATTLE.BattleText_GotAwaySafely);
|
||||
return true;
|
||||
}
|
||||
if (this.cannotFlee()) {
|
||||
@@ -2370,7 +2422,11 @@ export class Battle {
|
||||
this.strings[RAM.stringBuffer1] = this.options.data.itemName(this.player.mon.item);
|
||||
yield* this.say(BATTLE.BattleText_UserFledUsingAStringBuffer1);
|
||||
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
|
||||
yield* this.say(BATTLE.BattleText_GotAwaySafely);
|
||||
this.playSound({ stopDanger: true });
|
||||
this.playSound({ sfx: [SFX_RUN] });
|
||||
this.playSound({ stopDanger: true });
|
||||
this.playSound({ sfx: [SFX_RUN] });
|
||||
yield* this.say(BATTLE.BattleText_GotAwaySafely);
|
||||
return true;
|
||||
}
|
||||
this.numFleeAttempts = (this.numFleeAttempts + 1) & 0xff;
|
||||
@@ -2401,6 +2457,8 @@ export class Battle {
|
||||
return false;
|
||||
}
|
||||
this.battleResult = (this.battleResult & 0xc0) + C.DRAW;
|
||||
this.playSound({ stopDanger: true });
|
||||
this.playSound({ sfx: [SFX_RUN] });
|
||||
yield* this.say(BATTLE.BattleText_GotAwaySafely);
|
||||
return true;
|
||||
}
|
||||
@@ -5450,8 +5508,20 @@ export class Battle {
|
||||
}
|
||||
|
||||
private *handleEnemyMonFaint(): Gen {
|
||||
this.playSound({ sfx: [SFX_KINESIS, SFX_FAINT] });
|
||||
yield* this.say(BATTLE.BattleText_EnemyMonFainted);
|
||||
if (!this.player.mon.hp) yield* this.faintYourPokemon();
|
||||
if (!this.options.trainer) this.winSounds();
|
||||
// PlayVictoryMusic: a wild battle's, once someone gets the experience
|
||||
// or a Pay Day coin.
|
||||
if (
|
||||
!this.options.trainer &&
|
||||
this.options.party.some((mon) => mon.hp > 0) &&
|
||||
(this.player.mon.hp ||
|
||||
this.payDayMoney ||
|
||||
this.options.party.some((mon) => mon.hp > 0 && mon.item === C.EXP_SHARE))
|
||||
)
|
||||
this.playSound({ music: "victory" });
|
||||
yield* this.updateStateAndExperienceAfterEnemyFaint(false);
|
||||
if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle();
|
||||
yield this.hud();
|
||||
@@ -5497,6 +5567,7 @@ export class Battle {
|
||||
this.loadEnemyMonToSwitchTo();
|
||||
const switching = yield* this.offerSwitch();
|
||||
yield* this.say(BATTLE.BattleText_EnemySentOut);
|
||||
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
|
||||
yield this.hud();
|
||||
if (!switching) return;
|
||||
this.participants = this.participantsAll = 0;
|
||||
@@ -5508,6 +5579,7 @@ export class Battle {
|
||||
this.loadEnemyMonToSwitchTo();
|
||||
this.enemyIsSwitching = 1;
|
||||
yield* this.say(BATTLE.BattleText_EnemySentOut);
|
||||
this.playSound({ cry: this.enemy.mon.species, side: "enemy" });
|
||||
yield this.hud();
|
||||
}
|
||||
/** ResetEnemyBattleVars, CheckWhetherSwitchmonIsPredetermined (else the
|
||||
@@ -5638,6 +5710,7 @@ export class Battle {
|
||||
this.curBattleMon = current;
|
||||
if (turn === 0) {
|
||||
if (this.enemy.mon.hp) return false;
|
||||
this.playSound({ sfx: [SFX_KINESIS, SFX_FAINT] });
|
||||
yield* this.say(BATTLE.BattleText_EnemyMonFainted);
|
||||
return true;
|
||||
}
|
||||
@@ -5648,6 +5721,7 @@ export class Battle {
|
||||
outgoing.hp = this.player.mon.hp;
|
||||
if (this.player.mon.hp) return false;
|
||||
this.participants &= ~(1 << this.lastPlayerMon);
|
||||
this.playSound({ cry: this.player.mon.species, side: "player" });
|
||||
yield* this.say(BATTLE.BattleText_MonFainted);
|
||||
return true;
|
||||
}
|
||||
@@ -5677,6 +5751,9 @@ export class Battle {
|
||||
const text = sendOutText(hp, maxHp);
|
||||
if (!text) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
|
||||
yield* this.say(BATTLE[text]);
|
||||
const mon = this.player.mon;
|
||||
if (mon.hp && !(mon.status & (C.SLP_MASK | (1 << C.FRZ))))
|
||||
this.playSound({ cry: mon.species, side: "player" });
|
||||
}
|
||||
/** AskUseNextPokemon: only wild battles ask. NO tries to run, measured
|
||||
* with the first party Pokémon's Speed. Returns true if the player fled. */
|
||||
@@ -6227,12 +6304,18 @@ export class Battle {
|
||||
else if (c >= 100) score++;
|
||||
return score & 0xff;
|
||||
}
|
||||
/** FaintYourPokemon: StopDangerSound, then the cry. */
|
||||
private *faintYourPokemon(): Gen {
|
||||
this.playSound({ stopDanger: true });
|
||||
this.playSound({ cry: this.player.mon.species, side: "player" });
|
||||
yield* this.say(BATTLE.BattleText_MonFainted);
|
||||
}
|
||||
private *handlePlayerMonFaint(): Gen {
|
||||
yield* this.faintYourPokemon();
|
||||
if (!this.enemy.mon.hp) yield* this.say(BATTLE.BattleText_EnemyMonFainted);
|
||||
if (!this.enemy.mon.hp) {
|
||||
this.playSound({ sfx: [SFX_KINESIS, SFX_FAINT] });
|
||||
yield* this.say(BATTLE.BattleText_EnemyMonFainted);
|
||||
}
|
||||
this.updateFaintedPlayerMon();
|
||||
if (!this.options.party.some((mon) => mon.hp > 0)) return yield* this.lostBattle();
|
||||
if (!this.enemy.mon.hp) {
|
||||
|
||||
@@ -130,6 +130,7 @@ export class CrystalAssets {
|
||||
environment: r.byte(header + 2),
|
||||
paletteMode: r.byte(header + 7) & 15,
|
||||
phoneService: r.byte(header + 7) >> 4,
|
||||
music: r.byte(header + 6),
|
||||
fishGroup: r.byte(header + 8),
|
||||
attributes,
|
||||
blocks: r.slice(r.far(attributes + 3), blockWidth * blockHeight),
|
||||
|
||||
@@ -209,6 +209,8 @@ export const ENGINE_FLAGS = {
|
||||
allFruitTrees: 84,
|
||||
/** STATUSFLAGS2_BUG_CONTEST_TIMER_F is the same WRAM bit. */
|
||||
bugContestTimer: 17,
|
||||
rocketsInRadioTower: 19,
|
||||
rocketsInMahogany: 23,
|
||||
} as const;
|
||||
|
||||
/** Overworld scripts, routines and tables, keyed by their PRET labels so
|
||||
@@ -1063,6 +1065,20 @@ export const OPTIONS = {
|
||||
"Options_MenuAccount.On": 0xe44f6,
|
||||
} as const;
|
||||
|
||||
/** The sound engine (audio/engine.asm) and its tables, keyed by their
|
||||
* PRET labels. */
|
||||
export const AUDIO = {
|
||||
_UpdateSound: 0xe805c,
|
||||
FrequencyTable: 0xe8d80,
|
||||
WaveSamples: 0xe8db2,
|
||||
Drumkits: 0xe8e52,
|
||||
TrainerEncounterMusic: 0xe9027,
|
||||
Music: 0xe906e,
|
||||
Cries: 0xe91b0,
|
||||
SFX: 0xe927c,
|
||||
PokemonCries: 0xf2787,
|
||||
} as const;
|
||||
|
||||
/** Johto's scripts, routines and tables from Route 32 on, keyed by their
|
||||
* PRET labels for `npm run check:reference`. */
|
||||
export const JOHTO = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { GameWorld, TICKS_PER_SECOND } from "./world/world";
|
||||
import type { BattleAction } from "./battle/battle";
|
||||
import type { Direction } from "./world/types";
|
||||
import { WorldRenderer } from "./rendering/renderer";
|
||||
import { AudioOutput } from "./audio/output";
|
||||
|
||||
export class NativeRuntime implements GameRuntime {
|
||||
readonly id = "crystal-native-world-v1";
|
||||
@@ -17,6 +18,9 @@ export class NativeRuntime implements GameRuntime {
|
||||
private pending = 0;
|
||||
private published = "";
|
||||
private resizeObserver?: ResizeObserver;
|
||||
/** The synthesizer the world's sound engine feeds. */
|
||||
readonly audio = new AudioOutput();
|
||||
private volume = 1;
|
||||
constructor(
|
||||
private onUpdate: (world: GameWorld) => void,
|
||||
private onError: (error: unknown) => void,
|
||||
@@ -39,6 +43,12 @@ export class NativeRuntime implements GameRuntime {
|
||||
}
|
||||
play() {
|
||||
if (!this.world || this.raf) return;
|
||||
// Starting play is a user gesture in the app; browsers allow sound then.
|
||||
if (this.volume > 0) {
|
||||
this.audio.unlock();
|
||||
this.audio.resume();
|
||||
this.audio.send([this.world.sound.snapshot()]);
|
||||
}
|
||||
this.lastTime = 0;
|
||||
this.raf = requestAnimationFrame(this.tick);
|
||||
}
|
||||
@@ -48,8 +58,16 @@ export class NativeRuntime implements GameRuntime {
|
||||
this.lastTime = 0;
|
||||
this.pressed.clear();
|
||||
this.pending = 0;
|
||||
this.audio.suspend();
|
||||
this.world?.sound.takeFrames();
|
||||
if (this.world && this.renderer) this.renderer.render(this.world);
|
||||
}
|
||||
/** Sound volume, 0 (off) to 1. */
|
||||
setVolume(volume: number) {
|
||||
this.volume = volume;
|
||||
this.audio.setGain(volume);
|
||||
if (volume > 0 && this.raf) this.audio.unlock();
|
||||
}
|
||||
setButton(button: Button, pressed: boolean) {
|
||||
if (!pressed) {
|
||||
this.pressed.delete(button);
|
||||
@@ -57,6 +75,8 @@ export class NativeRuntime implements GameRuntime {
|
||||
}
|
||||
if (this.pressed.has(button) || !this.world) return;
|
||||
this.pressed.add(button);
|
||||
// Browsers start sound only from a user gesture such as this press.
|
||||
if (this.volume > 0) this.audio.unlock();
|
||||
const world = this.world;
|
||||
if (this.battleInput?.(button)) {
|
||||
this.publish();
|
||||
@@ -137,6 +157,8 @@ export class NativeRuntime implements GameRuntime {
|
||||
ticks++;
|
||||
}
|
||||
if (ticks === 6) this.pending = 0;
|
||||
const frames = world.sound.takeFrames();
|
||||
if (this.volume > 0) this.audio.send(frames);
|
||||
if (ticks) {
|
||||
this.renderer!.render(world);
|
||||
this.publishIfChanged();
|
||||
@@ -180,6 +202,7 @@ export class NativeRuntime implements GameRuntime {
|
||||
dialogue?.choice,
|
||||
world.liveState.money,
|
||||
dialogue?.holdTicks === undefined,
|
||||
world.waitingPage,
|
||||
]);
|
||||
if (signature === this.published) return;
|
||||
this.published = signature;
|
||||
@@ -262,6 +285,7 @@ export class NativeRuntime implements GameRuntime {
|
||||
dispose() {
|
||||
this.disconnectResizeObserver();
|
||||
this.pause();
|
||||
this.audio.dispose();
|
||||
this.world = undefined;
|
||||
this.renderer = undefined;
|
||||
}
|
||||
|
||||
+170
-23
@@ -4,6 +4,8 @@ import {
|
||||
type Decorations,
|
||||
} from "../world/decorations";
|
||||
import { RomReader } from "../rom/reader";
|
||||
import type { GameSound } from "../audio/sound";
|
||||
import { SOUND } from "../audio/constants";
|
||||
import { giveShuckle, returnShuckie, pokeSeer, photoStudio } from "./cianwood";
|
||||
import {
|
||||
EMPTY_ROUTINES,
|
||||
@@ -544,6 +546,10 @@ export type ScriptEffect =
|
||||
| { kind: "moveobject"; object: number; x: number; y: number }
|
||||
| { kind: "emote"; object: number; emote: number; ticks: number }
|
||||
| { kind: "pause"; ticks: number }
|
||||
/** WaitSFX: wait, frozen, until the sound effect channels are off. */
|
||||
| { kind: "waitsfx" }
|
||||
/** DelayFrames inside a special: the map waits, frozen. */
|
||||
| { kind: "delay"; ticks: number }
|
||||
/** A front picture in a box (pokepic); species 0 closes it. */
|
||||
| { kind: "picture"; species: number }
|
||||
/** The introduction's trainer or player picture; none closes it. */
|
||||
@@ -754,8 +760,19 @@ export interface ScriptHost {
|
||||
/** The map's tileset graphics and background palettes as shown now,
|
||||
* for specials that draw over the map. */
|
||||
tileset?(): { graphics: Uint8Array; palettes: Palette[] };
|
||||
/** The sound engine, and GetMapMusic_MaybeSpecial for this map. */
|
||||
sound?: GameSound;
|
||||
mapMusic?(state: ScriptState): number;
|
||||
}
|
||||
|
||||
/** A sound a script started while its text was still to be shown: it
|
||||
* plays when that page appears, as the original prints, then plays. */
|
||||
export type SoundCue = { page: number; play: () => void };
|
||||
/** The cues for a batch of pages, carried with the pages a text effect
|
||||
* shows (a hidden property, so the pages still compare as strings). */
|
||||
export const pageCues = (pages: readonly string[]): SoundCue[] =>
|
||||
(pages as { cues?: SoundCue[] }).cues ?? [];
|
||||
|
||||
export class UnsupportedScript extends Error {
|
||||
constructor(
|
||||
readonly offset: number,
|
||||
@@ -893,6 +910,10 @@ export class ScriptRun {
|
||||
private strengthSpecies = 0;
|
||||
private iterator: Generator<ScriptEffect, void, Input>;
|
||||
private instructions = 0;
|
||||
/** The run's sound helpers, for specials: play now or with the pending
|
||||
* page, and WaitSFX. */
|
||||
private cue: (play: () => void) => void = () => {};
|
||||
private waitSFX: () => Generator<ScriptEffect, void, Input> = function* () {};
|
||||
|
||||
constructor(
|
||||
private reader: RomReader,
|
||||
@@ -1028,12 +1049,36 @@ export class ScriptRun {
|
||||
return result;
|
||||
};
|
||||
const object = (id: number) => (id === LAST_TALKED ? this.lastTalked : id);
|
||||
// Sounds waiting for their page, and sounds a text command plays.
|
||||
const cues: SoundCue[] = [];
|
||||
const sound = this.host.sound;
|
||||
const cue = (play: () => void) => {
|
||||
if (!sound) return;
|
||||
if (pages.length) cues.push({ page: pages.length - 1, play });
|
||||
else play();
|
||||
};
|
||||
const write = (address: number) => {
|
||||
const content = readText(r, address, this.text());
|
||||
if (content) pages.push(...content.split("\n\n"));
|
||||
const sounds: { sfx: number; at: number }[] = [];
|
||||
const content = readText(r, address, {
|
||||
...this.text(),
|
||||
onSound: (sfx, at) => sounds.push({ sfx, at }),
|
||||
});
|
||||
if (content) {
|
||||
const base = pages.length;
|
||||
pages.push(...content.split("\n\n"));
|
||||
for (const { sfx, at } of sounds) {
|
||||
const page = base + content.slice(0, at).split("\n\n").length - 1;
|
||||
if (sound) cues.push({ page: Math.min(page, pages.length - 1), play: () => sound.playSFX(sfx) });
|
||||
}
|
||||
}
|
||||
if (pages.length > 64) throw new Error("Too many dialogue pages.");
|
||||
};
|
||||
const take = () => pages.splice(0, pages.length);
|
||||
const take = () => {
|
||||
const taken = pages.splice(0, pages.length);
|
||||
const mine = cues.splice(0, cues.length).filter((entry) => entry.page < taken.length);
|
||||
if (mine.length) Object.defineProperty(taken, "cues", { value: mine });
|
||||
return taken;
|
||||
};
|
||||
let picture = false;
|
||||
// Waits for the player when text is waiting; the DOM shows whole pages.
|
||||
const flush = function* (): Generator<ScriptEffect, void, Input> {
|
||||
@@ -1059,6 +1104,17 @@ export class ScriptRun {
|
||||
throw new Error("Map callbacks cannot present scenes.");
|
||||
return effect;
|
||||
};
|
||||
// WaitSFX: the script holds until the sound effects end. With text
|
||||
// still to show, the sound plays with its page and the reader waits.
|
||||
const waitSFX = function* (): Generator<ScriptEffect, void, Input> {
|
||||
if (!sound || pages.length || options.callback) return;
|
||||
if (sound.sfxPlaying) yield { kind: "waitsfx" };
|
||||
};
|
||||
const options = this.options;
|
||||
this.cue = cue;
|
||||
this.waitSFX = waitSFX;
|
||||
// MAPSETUP_RELOADMAP's ForceMapMusic.
|
||||
const reloadMapMusic = () => cue(() => sound!.forceMapMusic(this.state.playerState));
|
||||
const sceneOwner = (mapId: string) => this.host.sceneOwner(mapId);
|
||||
// GetWhiteoutSpawn/IsSpawnPoint: the table index of the last recorded
|
||||
// spawn map, or SPAWN_HOME when unset or no longer a listed spawn point.
|
||||
@@ -1571,12 +1627,14 @@ export class ScriptRun {
|
||||
throw new UnsupportedScript(offset, opcode, " (battle type)");
|
||||
yield* flush();
|
||||
yield synchronous({ kind: "battle", wild, tutorial: true });
|
||||
reloadMapMusic();
|
||||
wild = undefined;
|
||||
canLose = false;
|
||||
break;
|
||||
}
|
||||
case 0x60:
|
||||
// Script_reloadmapafterbattle: a loss continues with the whiteout.
|
||||
if ((battleResult & 0x3f) !== 1) reloadMapMusic();
|
||||
if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript;
|
||||
// BATTLESCRIPT_WILD_F is set by trainer battles, which run
|
||||
// MomTriesToBuySomething. Without a purchase it changes nothing.
|
||||
@@ -1600,6 +1658,7 @@ export class ScriptRun {
|
||||
// MAPSETUP_RELOADMAP restores graphics/audio after battle without
|
||||
// re-running callbacks or rebuilding objects. Native presentation
|
||||
// already retains the map; clear wBattleScriptFlags equivalents.
|
||||
reloadMapMusic();
|
||||
wild = undefined;
|
||||
trainer = undefined;
|
||||
canLose = false;
|
||||
@@ -2351,23 +2410,68 @@ export class ScriptRun {
|
||||
});
|
||||
break;
|
||||
}
|
||||
// Audio commands are consumed; native sound sequencing is pending.
|
||||
case 0x7f:
|
||||
case 0x84:
|
||||
case 0x85:
|
||||
word();
|
||||
// Audio: played now, or when the page printed before them shows.
|
||||
case 0x7f: {
|
||||
// playmusic
|
||||
const id = word();
|
||||
cue(() => sound!.scriptPlayMusic(id));
|
||||
break;
|
||||
case 0x81:
|
||||
word();
|
||||
byte();
|
||||
}
|
||||
case 0x80: {
|
||||
// encountermusic: wOtherTrainerClass's TrainerEncounterMusic.
|
||||
const trainerClass = trainer?.classId ?? this.tempTrainer?.classId ?? 0;
|
||||
cue(() => sound!.trainerEncounterMusic(trainerClass));
|
||||
break;
|
||||
case 0x80: // encountermusic
|
||||
case 0x82:
|
||||
case 0x83:
|
||||
case 0x86:
|
||||
case 0x87:
|
||||
case 0x88:
|
||||
}
|
||||
case 0x81: {
|
||||
// musicfadeout: the next song, then the fade's frames per step.
|
||||
const id = word(),
|
||||
rate = byte();
|
||||
cue(() => sound!.fadeOutTo(id, rate));
|
||||
break;
|
||||
}
|
||||
case 0x82: // playmapmusic
|
||||
cue(() => sound!.playMapMusic(this.host.mapMusic?.(this.state) ?? 0));
|
||||
break;
|
||||
case 0x83: // dontrestartmapmusic
|
||||
cue(() => sound!.dontRestartMapMusic());
|
||||
break;
|
||||
case 0x84: {
|
||||
// cry: a species, or wScriptVar's for 0; PlayMonCry waits.
|
||||
const species = word() & 0xff || value & 0xff;
|
||||
cue(() => sound!.playMonCry2(species));
|
||||
yield* waitSFX();
|
||||
break;
|
||||
}
|
||||
case 0x85: {
|
||||
// playsound
|
||||
const id = word();
|
||||
cue(() => sound!.playSFX(id));
|
||||
break;
|
||||
}
|
||||
case 0x86: // waitsfx
|
||||
yield* waitSFX();
|
||||
break;
|
||||
case 0x87: {
|
||||
// warpsound: GetWarpSFX by the tile the player stands on.
|
||||
const collision = this.host.playerCollision();
|
||||
const id =
|
||||
collision === OW.COLL_DOOR
|
||||
? SOUND.SFX_ENTER_DOOR
|
||||
: collision === OW.COLL_WARP_PANEL
|
||||
? SOUND.SFX_WARP_TO
|
||||
: SOUND.SFX_EXIT_BUILDING;
|
||||
cue(() => sound!.playSFX(id));
|
||||
break;
|
||||
}
|
||||
case 0x88: {
|
||||
// specialsound: SFX_GET_TM for a TM or HM, else SFX_ITEM; waits.
|
||||
const id =
|
||||
data.itemPocket(this.currentItem) === "tm" ? SOUND.SFX_GET_TM : SOUND.SFX_ITEM;
|
||||
cue(() => sound!.playSFX(id));
|
||||
yield* waitSFX();
|
||||
break;
|
||||
}
|
||||
case 0x8b: {
|
||||
const ticks = byte() || 256;
|
||||
if (pages.length) {
|
||||
@@ -2588,6 +2692,8 @@ export class ScriptRun {
|
||||
soldOut.add(index);
|
||||
bought = true;
|
||||
this.state.money -= total;
|
||||
// PlayTransactionSound
|
||||
this.host.sound?.playSFX(SOUND.SFX_TRANSACTION);
|
||||
write(texts.thanks);
|
||||
yield* flush();
|
||||
}
|
||||
@@ -2648,6 +2754,8 @@ export class ScriptRun {
|
||||
if (!confirmed) continue;
|
||||
tossItem(this.state.bag, slot.item, chosen);
|
||||
this.state.money = Math.min(C.MAX_MONEY, this.state.money + total);
|
||||
// SellMenu's PlayTransactionSound
|
||||
this.host.sound?.playSFX(SOUND.SFX_TRANSACTION);
|
||||
write(SYMBOLS.martBoughtText);
|
||||
yield* flush();
|
||||
}
|
||||
@@ -2663,6 +2771,7 @@ export class ScriptRun {
|
||||
strings: this.strings,
|
||||
numbers: this.numbers,
|
||||
decorationMenu: () => this.decorationMenu(),
|
||||
sound: this.host.sound,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3301,7 +3410,26 @@ export class ScriptRun {
|
||||
return value;
|
||||
}
|
||||
case SPECIAL_ROUTINES.restartMapMusic:
|
||||
this.cue(() => this.host.sound!.restartMapMusic());
|
||||
return value;
|
||||
case SPECIAL_ROUTINES.fadeOutMusic:
|
||||
// FadeOutMusic: to MUSIC_NONE, two frames a volume step.
|
||||
this.cue(() => this.host.sound!.fadeOutTo(0, 2));
|
||||
return value;
|
||||
case SPECIAL_ROUTINES.healMachineAnim: {
|
||||
// HealMachineAnim: SFX_SECOND_PART_OF_ITEMFINDER and 30 frames for
|
||||
// each Poké Ball placed, then MUSIC_HEAL over eight 10-frame
|
||||
// flashes. The map waits, frozen. No Pokémon, no animation.
|
||||
const sound = this.host.sound;
|
||||
if (!this.state.party.length || !sound || pages.length || this.options.callback) return value;
|
||||
for (let n = 0; n < this.state.party.length; n++) {
|
||||
sound.playSFX(SOUND.SFX_SECOND_PART_OF_ITEMFINDER);
|
||||
yield { kind: "delay", ticks: 15 };
|
||||
}
|
||||
sound.playMusic(SOUND.MUSIC_HEAL);
|
||||
yield { kind: "delay", ticks: 40 };
|
||||
return value;
|
||||
}
|
||||
case SPECIAL_ROUTINES.fadeOutToWhite:
|
||||
case SPECIAL_ROUTINES.warpToSpawnPoint:
|
||||
case SPECIAL_ROUTINES.stubbedTrainerRankingsHealings:
|
||||
@@ -3309,10 +3437,14 @@ export class ScriptRun {
|
||||
// StubbedTrainerRankings_Healings begins with `ret`: the Trainer
|
||||
// Rankings feature (Pokémon News) was cut, leaving a dead stub.
|
||||
return value;
|
||||
case OVERWORLD.UpdatePlayerSprite:
|
||||
case OVERWORLD.PlayMapMusic:
|
||||
case OVERWORLD.UpdateTimePals:
|
||||
this.cue(() => this.host.sound!.playMapMusic(this.host.mapMusic?.(this.state) ?? 0));
|
||||
return value;
|
||||
case OVERWORLD.WaitSFX:
|
||||
yield* this.waitSFX();
|
||||
return value;
|
||||
case OVERWORLD.UpdatePlayerSprite:
|
||||
case OVERWORLD.UpdateTimePals:
|
||||
case OVERWORLD.FadeInFromWhite:
|
||||
case OVERWORLD.LoadUsedSpritesGFX:
|
||||
case OVERWORLD.RefreshSprites:
|
||||
@@ -3324,7 +3456,6 @@ export class ScriptRun {
|
||||
// slow_step in the facing direction, then step_end.
|
||||
this.movementBuffer = [0x08 | this.host.facing(), 0x47];
|
||||
return value;
|
||||
case SPECIAL_ROUTINES.healMachineAnim:
|
||||
case SPECIAL_ROUTINES.fadeOutToBlack:
|
||||
case SPECIAL_ROUTINES.fadeInFromBlack:
|
||||
case SPECIAL_ROUTINES.reloadSpritesNoPalettes:
|
||||
@@ -3469,10 +3600,25 @@ export class ScriptRun {
|
||||
case JOHTO.PhotoStudio:
|
||||
yield* photoStudio(this.specialContext(pages, take));
|
||||
return value;
|
||||
case JOHTO.PlaySlowCry:
|
||||
case JOHTO.PlayCurMonCry:
|
||||
// Cry pitch/duration is audio, like the ordinary cry opcode.
|
||||
case JOHTO.PlaySlowCry: {
|
||||
// PlaySlowCry: wScriptVar's cry, pitch - $140 and length + $60.
|
||||
const species = value & 0xff;
|
||||
this.cue(() => {
|
||||
const sound = this.host.sound!;
|
||||
const { id, pitch, length } = sound.engine.cryEntry(species);
|
||||
if (species >= 1 && species <= 251)
|
||||
sound.engine.playCryWith(id, (pitch - 0x140) & 0xffff, (length + 0x60) & 0xffff);
|
||||
});
|
||||
yield* this.waitSFX();
|
||||
return value;
|
||||
}
|
||||
case JOHTO.PlayCurMonCry: {
|
||||
// PlayMonCry for wCurPartySpecies, the Pokémon just chosen.
|
||||
const species = this.state.party[this.curPartyMon]?.species ?? 0;
|
||||
this.cue(() => this.host.sound!.playMonCry2(species));
|
||||
yield* this.waitSFX();
|
||||
return value;
|
||||
}
|
||||
case JOHTO.FindPartyMonThatSpecies:
|
||||
return findPartyMonThatSpecies(this.specialContext(pages, take), value);
|
||||
case JOHTO.CheckMagikarpLength:
|
||||
@@ -3503,6 +3649,7 @@ export class ScriptRun {
|
||||
dex: new PokedexData(r),
|
||||
...this.state.pokedex,
|
||||
firstUnownSeen: this.state.firstUnownSeen,
|
||||
sound: this.host.sound,
|
||||
}).newEntry(value);
|
||||
return value;
|
||||
case JOHTO.OlderHaircutBrother:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GameSound } from "../audio/sound";
|
||||
import { SOUND } from "../audio/constants";
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import { INTRO } from "../rom/profile";
|
||||
import {
|
||||
@@ -27,6 +29,8 @@ export interface IntroHost {
|
||||
now(): Date;
|
||||
/** Text values, with `<PLAYER>` read as `player`. */
|
||||
text(player: string): TextContext;
|
||||
/** The sound engine: OakSpeech's music and Wooper's cry. */
|
||||
sound?: GameSound;
|
||||
}
|
||||
|
||||
/** A new game's introduction: NewGame's PlayerProfileSetup (InitGender)
|
||||
@@ -100,12 +104,20 @@ export class IntroRun {
|
||||
);
|
||||
this.avatar = gender === 1 ? "kris" : "chris";
|
||||
yield* this.initClock();
|
||||
// OakSpeech: MUSIC_ROUTE_30 after InitClock.
|
||||
const sound = this.host.sound;
|
||||
sound?.playMusic(SOUND.MUSIC_ROUTE_30);
|
||||
yield { kind: "portrait", portrait: { trainer: POKEMON_PROF } };
|
||||
yield { kind: "text", pages: this.pages(INTRO._OakText1) };
|
||||
// OakText2's text_asm plays Wooper's cry; OakText3 is a button wait.
|
||||
yield { kind: "portrait" };
|
||||
yield { kind: "picture", species: WOOPER };
|
||||
yield { kind: "text", pages: this.pages(INTRO._OakText2) };
|
||||
const oakText2 = this.pages(INTRO._OakText2);
|
||||
if (sound)
|
||||
Object.defineProperty(oakText2, "cues", {
|
||||
value: [{ page: oakText2.length - 1, play: () => sound.playMonCry2(WOOPER) }],
|
||||
});
|
||||
yield { kind: "text", pages: oakText2 };
|
||||
yield { kind: "text", pages: this.pages(INTRO._OakText4) };
|
||||
yield { kind: "picture", species: 0 };
|
||||
yield { kind: "portrait", portrait: { trainer: POKEMON_PROF } };
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GameSound } from "../audio/sound";
|
||||
import { SOUND } from "../audio/constants";
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import { ENGINE_FLAGS, MENU, PC, RAM } from "../rom/profile";
|
||||
@@ -37,6 +39,8 @@ export interface PcHost {
|
||||
numbers: Record<number, number>;
|
||||
/** _PlayerDecorationMenu: true if a decoration changed. */
|
||||
decorationMenu(): Gen<boolean>;
|
||||
/** The sound engine, for the PC's boot, choice and shutdown sounds. */
|
||||
sound?: GameSound;
|
||||
}
|
||||
|
||||
/** PokemonCenterPC's menu items (PCPCITEM_*). */
|
||||
@@ -144,6 +148,8 @@ export class PcSession {
|
||||
yield* this.print(PC["PC_CheckPartyForPokemon.PokecenterPCCantUseText"]);
|
||||
return;
|
||||
}
|
||||
// PC_PlayBootSound
|
||||
this.host.sound?.playSFX(SOUND.SFX_BOOT_PC);
|
||||
yield* this.print(PC.PokecenterPCTurnOnText);
|
||||
const whose = this.pages(PC.PokecenterPCWhoseText);
|
||||
let cursor = 0;
|
||||
@@ -160,6 +166,10 @@ export class PcSession {
|
||||
undefined,
|
||||
cursor,
|
||||
);
|
||||
// PC_PlayShutdownSound when leaving; PC_PlayChoosePCSound otherwise.
|
||||
const sound = this.host.sound;
|
||||
if (choice < 0 || items[choice] === PCPCITEM_TURN_OFF) sound?.playSFX(SOUND.SFX_SHUT_DOWN_PC);
|
||||
else sound?.playSFX(SOUND.SFX_CHOOSE_PC_OPTION);
|
||||
if (choice < 0) return;
|
||||
cursor = choice;
|
||||
switch (items[choice]) {
|
||||
@@ -187,6 +197,7 @@ export class PcSession {
|
||||
/** _PlayersHousePC: returns true when a decoration changed, which makes
|
||||
* the script reload the room. */
|
||||
*playersHousePC(): Gen<boolean> {
|
||||
this.host.sound?.playSFX(SOUND.SFX_BOOT_PC);
|
||||
yield* this.print(PC.PlayersPCTurnOnText);
|
||||
return yield* this.playersPC(true);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GameSound } from "../audio/sound";
|
||||
import { SOUND } from "../audio/constants";
|
||||
import { NUM_POKEMON } from "../rom/profile";
|
||||
import type { PokedexData } from "../rom/pokedex";
|
||||
import { dexHeight, dexWeight } from "../rom/pokedex";
|
||||
@@ -86,13 +88,15 @@ export interface PokedexHost {
|
||||
caught: ReadonlySet<number>;
|
||||
/** wFirstUnownSeen, the letter Unown's picture shows (0 before any). */
|
||||
firstUnownSeen?: number;
|
||||
/** The sound engine, for the entries' cries and the closing click. */
|
||||
sound?: GameSound;
|
||||
}
|
||||
|
||||
/** Pokedex (engine/pokedex/pokedex.asm) in New mode: the main list and
|
||||
* the entry screen. B leaves the list; A opens a seen Pokémon's entry,
|
||||
* where left and right move the arrow cursor, up and down step through
|
||||
* seen Pokémon, A runs the action under the cursor and B returns. Only
|
||||
* PAGE acts: AREA, CRY and PRNT, the option (SELECT) and search (START)
|
||||
* seen Pokémon, A runs the action under the cursor and B returns. PAGE
|
||||
* and CRY act: AREA and PRNT, the option (SELECT) and search (START)
|
||||
* screens and the Unown mode are not ported. */
|
||||
export class Pokedex {
|
||||
/** wPokedexOrder, in DEXMODE_NEW. */
|
||||
@@ -118,7 +122,11 @@ export class Pokedex {
|
||||
*run(): Gen {
|
||||
for (;;) {
|
||||
const button = yield* this.button(this.listView());
|
||||
if (button === "b") break;
|
||||
if (button === "b") {
|
||||
// Pokedex's .exit: SFX_READ_TEXT_2.
|
||||
this.host.sound?.playSFX(SOUND.SFX_READ_TEXT_2);
|
||||
break;
|
||||
}
|
||||
if (button === "a") {
|
||||
if (!this.host.seen.has(this.selected())) continue;
|
||||
yield* this.entry();
|
||||
@@ -137,6 +145,8 @@ export class Pokedex {
|
||||
for (const page of [1, 2] as const) {
|
||||
const view = this.entryView(page, 0, species);
|
||||
view.entry!.actions = [];
|
||||
// NewPokedexEntry's PlayMonCry, with the first page.
|
||||
if (page === 1) this.host.sound?.playMonCry2(species);
|
||||
for (;;) {
|
||||
const button = yield* this.button(view);
|
||||
if (button === "a" || button === "b") break;
|
||||
@@ -157,6 +167,8 @@ export class Pokedex {
|
||||
let page: 1 | 2 = 1;
|
||||
let action = 0;
|
||||
this.prevEntry = this.selected();
|
||||
// Pokedex_InitDexEntryScreen's PlayMonCry.
|
||||
this.host.sound?.playMonCry2(this.selected());
|
||||
for (;;) {
|
||||
const button = yield* this.button(this.entryView(page, action));
|
||||
// Pokedex_MoveArrowCursor reads left and right only.
|
||||
@@ -164,21 +176,23 @@ export class Pokedex {
|
||||
else if (button === "right" && action < ENTRY_ACTIONS - 1) action++;
|
||||
if (button === "b") return;
|
||||
if (button === "a") {
|
||||
// Pokedex_Page; .Area, .Cry and .Print are not ported.
|
||||
// Pokedex_Page and .Cry; .Area and .Print are not ported.
|
||||
if (action === 0) {
|
||||
page = page === 1 ? 2 : 1;
|
||||
this.prevEntry = this.selected();
|
||||
}
|
||||
} else if (action === 2) this.host.sound?.playCry(this.selected());
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
(button === "up" || button === "down") &&
|
||||
this.nextOrPrevious(button)
|
||||
) {
|
||||
// Pokedex_ReinitDexEntryScreen: page 1 and the cursor on PAGE.
|
||||
// Pokedex_ReinitDexEntryScreen: page 1, the cursor on PAGE, and
|
||||
// the new Pokémon's cry.
|
||||
page = 1;
|
||||
action = 0;
|
||||
this.prevEntry = this.selected();
|
||||
this.host.sound?.playMonCry2(this.selected());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SOUND } from "../audio/constants";
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import type { PokedexData } from "../rom/pokedex";
|
||||
@@ -363,6 +364,7 @@ export class StartMenuRun {
|
||||
seen: this.state.pokedex.seen,
|
||||
caught: this.state.pokedex.caught,
|
||||
firstUnownSeen: this.state.firstUnownSeen,
|
||||
sound: this.host.scriptHost().sound,
|
||||
},
|
||||
this.cursors.dex,
|
||||
);
|
||||
@@ -1455,6 +1457,10 @@ export class StartMenuRun {
|
||||
this.reader.byte(BATTLE.HappinessChanges + (action - 1) * 3 + tier),
|
||||
);
|
||||
}
|
||||
/** A sound effect, when the game has sound. */
|
||||
private sfx(id: number) {
|
||||
this.host.scriptHost().sound?.playSFX(id);
|
||||
}
|
||||
/** The new HP, with HealHP_SFX_GFX's delta for the action text. */
|
||||
private setHP(mon: PartyMon, hp: number) {
|
||||
this.numbers[RAM.curHPAnimDeltaHP] = hp - mon.hp;
|
||||
@@ -1476,6 +1482,7 @@ export class StartMenuRun {
|
||||
}
|
||||
if (amount === undefined) throw new Error("Missing healing amount.");
|
||||
this.setHP(mon, restoreHealth(mon.hp, mon.stats[0], amount));
|
||||
this.sfx(SOUND.SFX_POTION); // HealHP_SFX_GFX
|
||||
yield* this.itemActionText(target, C.PARTYMENUTEXT_HEAL_HP);
|
||||
this.useDisposableItem(use);
|
||||
return 0;
|
||||
@@ -1497,6 +1504,7 @@ export class StartMenuRun {
|
||||
const { text, mask } = this.healingAction(use.item);
|
||||
if (!(mon.status & mask)) return 1;
|
||||
mon.status = 0;
|
||||
this.sfx(SOUND.SFX_FULL_HEAL); // Play_SFX_FULL_HEAL
|
||||
yield* this.itemActionText(target, text);
|
||||
this.useDisposableItem(use);
|
||||
return 0;
|
||||
@@ -1513,6 +1521,7 @@ export class StartMenuRun {
|
||||
);
|
||||
this.setHP(mon, mon.stats[0]);
|
||||
mon.status = 0;
|
||||
this.sfx(SOUND.SFX_POTION);
|
||||
yield* this.itemActionText(target, C.PARTYMENUTEXT_HEAL_HP);
|
||||
this.useDisposableItem(use);
|
||||
return true;
|
||||
@@ -1523,6 +1532,7 @@ export class StartMenuRun {
|
||||
const mon = this.state.party[target];
|
||||
if (mon.hp) return 1;
|
||||
this.setHP(mon, use.item === C.REVIVE ? mon.stats[0] >> 1 : mon.stats[0]);
|
||||
this.sfx(SOUND.SFX_POTION);
|
||||
yield* this.itemActionText(target, C.PARTYMENUTEXT_REVIVE);
|
||||
this.useDisposableItem(use);
|
||||
return 0;
|
||||
|
||||
@@ -86,8 +86,22 @@ export interface TextContext {
|
||||
/** Native handlers for text_asm, keyed by the command's ROM offset. They
|
||||
* return where text continues, as the original routine returns in hl. */
|
||||
asm?: (offset: number) => number | undefined;
|
||||
/** TextCommand_SOUND's sound effect (TextSFX), with the length of the
|
||||
* text printed before it. */
|
||||
onSound?: (sfx: number, at: number) => void;
|
||||
}
|
||||
|
||||
/** TextSFX: the text commands that play a sound effect, and its id. */
|
||||
const TEXT_SFX: Readonly<Record<number, number>> = {
|
||||
0x0b: 0x00, // SFX_DEX_FANFARE_50_79
|
||||
0x0e: 0x09, // SFX_DEX_FANFARE_20_49
|
||||
0x0f: 0x01, // SFX_ITEM
|
||||
0x10: 0x02, // SFX_CAUGHT_MON
|
||||
0x11: 0x0a, // SFX_DEX_FANFARE_80_109
|
||||
0x12: 0x2d, // SFX_FANFARE
|
||||
0x13: 0x2c, // SFX_SLOT_MACHINE_START
|
||||
};
|
||||
|
||||
export const defaultTextContext = (player: string): TextContext => ({
|
||||
player,
|
||||
// New games initialize these names before the player can choose them.
|
||||
@@ -252,6 +266,7 @@ export function readTextEnd(
|
||||
break;
|
||||
}
|
||||
case 0x0a:
|
||||
break; // Timed pauses belong to presentation.
|
||||
case 0x0b:
|
||||
case 0x0e:
|
||||
case 0x0f:
|
||||
@@ -259,7 +274,8 @@ export function readTextEnd(
|
||||
case 0x11:
|
||||
case 0x12:
|
||||
case 0x13:
|
||||
break; // Timed pauses and sound cues belong to presentation/audio.
|
||||
context.onSound?.(TEXT_SFX[command], output.length);
|
||||
break;
|
||||
case 0x0c:
|
||||
output += "…".repeat(reader.byte(p++));
|
||||
break;
|
||||
@@ -275,10 +291,12 @@ export function readTextEnd(
|
||||
output += context.weekday;
|
||||
break;
|
||||
case 0x16: {
|
||||
const before = output.length,
|
||||
onSound = context.onSound;
|
||||
const nested = readTextEnd(
|
||||
reader,
|
||||
reader.near(p, reader.byte(p + 2)),
|
||||
context,
|
||||
onSound ? { ...context, onSound: (sfx, at) => onSound(sfx, before + at) } : context,
|
||||
depth + 1,
|
||||
);
|
||||
output += nested.text;
|
||||
|
||||
@@ -26,6 +26,7 @@ export const OW = {
|
||||
COLL_DOOR_79: 0x79,
|
||||
COLL_STAIRCASE: 0x7a,
|
||||
COLL_CAVE: 0x7b,
|
||||
COLL_WARP_PANEL: 0x7c,
|
||||
COLL_WARP_CARPET_RIGHT: 0x7e,
|
||||
COLL_COUNTER: 0x90,
|
||||
HI_NYBBLE_CURRENT: 0x30,
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface GameMap {
|
||||
/** The palette byte's high nybble: nonzero where the phone has no
|
||||
* signal (GetMapPhoneService). */
|
||||
phoneService: number;
|
||||
/** MAP_MUSIC: the song id, MUSIC_MAHOGANY_MART or RADIO_TOWER_MUSIC | id. */
|
||||
music: number;
|
||||
/** MAP_FISHGROUP: the FishGroups entry the rods fish from, or
|
||||
* FISHGROUP_NONE. */
|
||||
fishGroup: number;
|
||||
|
||||
+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,
|
||||
|
||||
+18
-1
@@ -11,6 +11,8 @@ import { renderPokedexScreen, renderPokedexTab } from "./ui/pokedex";
|
||||
import {
|
||||
RESERVED_KEYS,
|
||||
TEXT_SCALES,
|
||||
VOLUMES,
|
||||
type Volume,
|
||||
applyTextScale,
|
||||
buttonKeys,
|
||||
defaultPreferences,
|
||||
@@ -93,7 +95,12 @@ function showWorld(world: GameWorld) {
|
||||
get("town-map-view").hidden = !world.townMap;
|
||||
if (world.townMap)
|
||||
drawTownMap(get<HTMLCanvasElement>("town-map-canvas"), world);
|
||||
const dialogue = world.dialogue;
|
||||
// While a script waits for its sound, the last page stays in the box.
|
||||
const dialogue =
|
||||
world.dialogue ??
|
||||
(world.waitingPage !== undefined
|
||||
? { pages: [world.waitingPage], index: 0, holdTicks: 0 }
|
||||
: undefined);
|
||||
showPicture(world);
|
||||
// A page without text is a button wait while a picture is shown.
|
||||
const pictureWait = Boolean(
|
||||
@@ -1696,6 +1703,7 @@ const input = new InputController((button, pressed) =>
|
||||
let preferences = loadPreferences();
|
||||
let keys = keyMap(preferences);
|
||||
applyTextScale(preferences.textScale);
|
||||
runtime.setVolume(preferences.volume);
|
||||
const controls = bindControls(
|
||||
root,
|
||||
input,
|
||||
@@ -2021,6 +2029,7 @@ function setPreferences(next: Preferences, status = "") {
|
||||
keys = keyMap(preferences);
|
||||
savePreferences(preferences);
|
||||
applyTextScale(preferences.textScale);
|
||||
runtime.setVolume(preferences.volume);
|
||||
get("game-screen").setAttribute("aria-label", `Game screen. ${controlsSummary()}`);
|
||||
showSettings();
|
||||
get("settings-status").textContent = status;
|
||||
@@ -2028,6 +2037,8 @@ function setPreferences(next: Preferences, status = "") {
|
||||
function showSettings() {
|
||||
for (const radio of document.querySelectorAll<HTMLInputElement>('input[name="text-scale"]'))
|
||||
radio.checked = Number(radio.value) === preferences.textScale;
|
||||
for (const radio of document.querySelectorAll<HTMLInputElement>('input[name="volume"]'))
|
||||
radio.checked = Number(radio.value) === preferences.volume;
|
||||
get("key-table").replaceChildren(
|
||||
...BUTTON_ORDER.map((button) => {
|
||||
const row = document.createElement("tr"),
|
||||
@@ -2112,6 +2123,12 @@ for (const radio of document.querySelectorAll<HTMLInputElement>('input[name="tex
|
||||
if (TEXT_SCALES.includes(scale))
|
||||
setPreferences({ ...preferences, textScale: scale }, `Text size: ${radio.parentElement?.textContent?.trim()}.`);
|
||||
});
|
||||
for (const radio of document.querySelectorAll<HTMLInputElement>('input[name="volume"]'))
|
||||
radio.addEventListener("change", () => {
|
||||
const volume = Number(radio.value) as Volume;
|
||||
if (VOLUMES.includes(volume))
|
||||
setPreferences({ ...preferences, volume }, `Sound: ${radio.parentElement?.textContent?.trim()}.`);
|
||||
});
|
||||
get("reset-keys").addEventListener("click", () =>
|
||||
setPreferences({ ...preferences, keys: defaultPreferences().keys }, "Controls reset to the defaults."),
|
||||
);
|
||||
|
||||
@@ -7,9 +7,13 @@ export interface Preferences {
|
||||
textScale: TextScale;
|
||||
/** The keyboard codes (KeyboardEvent.code) that press each button. */
|
||||
keys: Record<Button, string[]>;
|
||||
/** Sound volume: 0 is off. */
|
||||
volume: Volume;
|
||||
}
|
||||
|
||||
export const TEXT_SCALES = [1, 1.25, 1.5] as const;
|
||||
export const VOLUMES = [0, 0.3, 0.6, 1] as const;
|
||||
export type Volume = (typeof VOLUMES)[number];
|
||||
export type TextScale = (typeof TEXT_SCALES)[number];
|
||||
|
||||
export const DEFAULT_KEYS: Readonly<Record<Button, readonly string[]>> = {
|
||||
@@ -31,6 +35,7 @@ const STORAGE_KEY = "crystal-preferences-v1";
|
||||
|
||||
export const defaultPreferences = (): Preferences => ({
|
||||
textScale: 1,
|
||||
volume: 0.6,
|
||||
keys: Object.fromEntries(
|
||||
BUTTONS.map((button) => [button, [...DEFAULT_KEYS[button]]]),
|
||||
) as Record<Button, string[]>,
|
||||
@@ -44,6 +49,7 @@ function parse(value: unknown): Preferences {
|
||||
const stored = value as Partial<Preferences>;
|
||||
if (TEXT_SCALES.includes(stored.textScale as TextScale))
|
||||
preferences.textScale = stored.textScale as TextScale;
|
||||
if (VOLUMES.includes(stored.volume as Volume)) preferences.volume = stored.volume as Volume;
|
||||
const keys = stored.keys;
|
||||
if (keys && typeof keys === "object") {
|
||||
const seen = new Set<string>();
|
||||
|
||||
+1
-1
@@ -82,5 +82,5 @@ export function renderShell(root: HTMLElement) {
|
||||
</div>
|
||||
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream">
|
||||
<dialog id="help-dialog" aria-labelledby="help-title"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2 id="help-title">Playing Crystal in your browser</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl id="help-keys"></dl><p>On a touch screen, use the on-screen pad and buttons. Settings changes the keys and the text size.</p><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play. The Map tab lists the areas open in this port; the others appear at the edges but cannot be entered yet.</p></dialog>
|
||||
<dialog id="settings-dialog" aria-labelledby="settings-title"><button id="close-settings" class="dialog-close" aria-label="Close settings">×</button><p class="menu-title">SETTINGS</p><h2 id="settings-title">Display and controls</h2><fieldset class="text-size"><legend>Text size</legend><label><input type="radio" name="text-scale" value="1"> Standard</label><label><input type="radio" name="text-scale" value="1.25"> Large</label><label><input type="radio" name="text-scale" value="1.5"> Largest</label></fieldset><section class="key-settings" aria-labelledby="keys-title"><h3 id="keys-title">Keyboard controls</h3><p>Choose Change, then press the key to use. Tab, Escape and Space keep their usual roles.</p><table><thead><tr><th scope="col">Button</th><th scope="col">Key</th><th scope="col"><span class="visually-hidden">Change</span></th></tr></thead><tbody id="key-table"></tbody></table><button id="reset-keys" class="menu-button">Reset controls</button><p id="settings-status" class="settings-status" role="status" aria-live="polite"></p></section></dialog>`;
|
||||
<dialog id="settings-dialog" aria-labelledby="settings-title"><button id="close-settings" class="dialog-close" aria-label="Close settings">×</button><p class="menu-title">SETTINGS</p><h2 id="settings-title">Display, sound and controls</h2><fieldset class="text-size"><legend>Text size</legend><label><input type="radio" name="text-scale" value="1"> Standard</label><label><input type="radio" name="text-scale" value="1.25"> Large</label><label><input type="radio" name="text-scale" value="1.5"> Largest</label></fieldset><fieldset class="text-size"><legend>Sound</legend><label><input type="radio" name="volume" value="0"> Off</label><label><input type="radio" name="volume" value="0.3"> Quiet</label><label><input type="radio" name="volume" value="0.6"> Medium</label><label><input type="radio" name="volume" value="1"> Full</label></fieldset><section class="key-settings" aria-labelledby="keys-title"><h3 id="keys-title">Keyboard controls</h3><p>Choose Change, then press the key to use. Tab, Escape and Space keep their usual roles.</p><table><thead><tr><th scope="col">Button</th><th scope="col">Key</th><th scope="col"><span class="visually-hidden">Change</span></th></tr></thead><tbody id="key-table"></tbody></table><button id="reset-keys" class="menu-button">Reset controls</button><p id="settings-status" class="settings-status" role="status" aria-live="polite"></p></section></dialog>`;
|
||||
}
|
||||
Reference in new issue
Block a user