Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export default tseslint.config(
},
},
{
files: ["src/game/**/*.ts"],
files: ["src/game/**/*.ts", "packages/arcade-engine/src/**/*.ts"],
rules: {
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-this-alias": "off",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "time-pilot",
"private": true,
"version": "27.1.0",
"version": "29.2.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
10 changes: 10 additions & 0 deletions packages/arcade-engine/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "@time-pilot/arcade-engine",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"types": "./src/index.ts"
}
67 changes: 41 additions & 26 deletions src/game/engine/Sound.ts → packages/arcade-engine/src/Sound.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
/* Converted from engine/Sound.js (AMD) to ESM TypeScript. */
import { logger } from "../logger";
import userOptions from "../user-options";
import type {
SoundChannel,
SoundEngineConfiguration,
SoundPlaybackBlockedDetails,
} from "./types";

interface SoundOptions {
loop?: boolean;
autoplay?: boolean;
instantDestroy?: boolean;
channel?: "effects" | "music";
channel?: SoundChannel;
onEnded?: () => void;
}

Expand All @@ -30,8 +33,12 @@ class Sound {
private static _instances = new Set<Sound>();
private static _isMuted = false;
private static _pausedInstances = new Set<Sound>();
private static _isListeningForUserOptions = false;
private readonly _channel: "effects" | "music";
private static _getVolume: (channel: SoundChannel) => number = () => 1;
private static _onPlaybackBlocked?: (
details: SoundPlaybackBlockedDetails
) => void;
private static _volumeChangeEventCleanup?: () => void;
private readonly _channel: SoundChannel;
private readonly _instantDestroy: boolean;
private readonly _onEnded?: () => void;
private readonly _urls: string[];
Expand Down Expand Up @@ -67,6 +74,33 @@ class Sound {
Sound._isMuted = isMuted;
};

static configure = (configuration: SoundEngineConfiguration = {}): void => {
Sound._volumeChangeEventCleanup?.();
Sound._volumeChangeEventCleanup = undefined;
Sound._getVolume = configuration.getVolume ?? (() => 1);
Sound._onPlaybackBlocked = configuration.onPlaybackBlocked;

if (
configuration.volumeChangeEventName &&
configuration.volumeChangeEventTarget
) {
const refreshVolumes = () => Sound.refreshAllVolumes();

configuration.volumeChangeEventTarget.addEventListener(
configuration.volumeChangeEventName,
refreshVolumes
);
Sound._volumeChangeEventCleanup = () => {
configuration.volumeChangeEventTarget?.removeEventListener(
configuration.volumeChangeEventName as string,
refreshVolumes
);
};
}

Sound.refreshAllVolumes();
};

static pauseAll = (): void => {
Sound._pausedInstances.clear();

Expand Down Expand Up @@ -136,7 +170,6 @@ class Sound {

this._theSound.addEventListener("canplay", this._markCanPlay, false);
this._theSound.addEventListener("ended", this._markEnded, false);
Sound.listenForUserOptionChanges();
Sound._instances.add(this);
}

Expand Down Expand Up @@ -252,17 +285,10 @@ class Sound {
};

private applyVolume = (): void => {
const channelVolume =
this._channel === "music"
? userOptions.musicVolume
: userOptions.effectsVolume;

this._theSound.volume =
Sound._isMuted && this._channel === "effects"
? 0
: (userOptions.masterVolume / 10) *
(channelVolume / 10) *
this._fadeMultiplier;
: Sound._getVolume(this._channel) * this._fadeMultiplier;
};

private cancelFade = (): void => {
Expand Down Expand Up @@ -309,17 +335,6 @@ class Sound {
this._fadeFrame = window.requestAnimationFrame(update);
};

private static listenForUserOptionChanges = (): void => {
if (Sound._isListeningForUserOptions) {
return;
}

window.addEventListener("timePilot:userOptionsChanged", () => {
Sound.refreshAllVolumes();
});
Sound._isListeningForUserOptions = true;
};

private playElement = (): void => {
this.ensureSpatialAudio();
const playPromise = this._theSound.play();
Expand All @@ -330,7 +345,7 @@ class Sound {
void playPromise.catch(() => {
this._isPlaying = false;
Sound._pausedInstances.delete(this);
logger.warning("Audio playback was blocked", {
Sound._onPlaybackBlocked?.({
channel: this._channel,
Comment thread
manix84 marked this conversation as resolved.
sources: this._urls,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* Converted from engine/Ticker.js (AMD) to ESM TypeScript. */
import type { TickerInstance } from "../types";
import type { TickerInstance } from "./types";

type LegacyAnimationWindow = Window &
typeof globalThis & {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,49 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import userOptions from "../../user-options";
import {
drawDebugVectors,
getScaledViewportLimit,
getViewportAreaScale,
getViewportPaddedRadius,
getViewportRadius,
} from "../index";
import GameArena from "../arena";
import Sound from "../Sound";
import Ticker from "../Ticker";

const soundOptionsChangedEvent = "test:soundOptionsChanged";
let volumeSettings = {
effects: 8,
master: 10,
music: 8,
};

const dispatchSoundOptionsChanged = (): void => {
window.dispatchEvent(new CustomEvent(soundOptionsChangedEvent));
};

describe("engine modules", () => {
beforeEach(() => {
vi.clearAllMocks();
volumeSettings = {
effects: 8,
master: 10,
music: 8,
};
Sound.configure({
getVolume: (channel) => {
const channelVolume =
channel === "music" ? volumeSettings.music : volumeSettings.effects;

return (volumeSettings.master / 10) * (channelVolume / 10);
},
volumeChangeEventName: soundOptionsChangedEvent,
volumeChangeEventTarget: window,
});
});

afterEach(() => {
userOptions.setOption("masterVolume", 10);
userOptions.setOption("musicVolume", 8);
userOptions.setOption("effectsVolume", 8);
Sound.destroyAll();
Sound.configure();
vi.unstubAllGlobals();
});

Expand Down Expand Up @@ -191,6 +221,56 @@ describe("engine modules", () => {
expect(arena.canToggleFullScreen()).toBe(true);
});

it("calculates viewport radii and scaled limits", () => {
const viewport = { width: 800, height: 600 };

expect(getViewportRadius(viewport)).toBe(500);
expect(
getViewportPaddedRadius(viewport, {
minRadius: 700,
padding: 96,
})
).toBe(700);
expect(getViewportPaddedRadius(viewport, { padding: 96 })).toBe(596);
expect(getViewportAreaScale({ width: 1600, height: 1200 })).toBe(4);
expect(getScaledViewportLimit(3, { width: 1200, height: 800 })).toBe(6);
});

it("draws debug heading and steering vectors with caller-provided colors", () => {
const host = document.createElement("div");
const arena = new GameArena(host);
const context = arena.getContext() as CanvasRenderingContext2D;
const beginPath = vi.spyOn(context, "beginPath");
const fill = vi.spyOn(context, "fill");
const lineTo = vi.spyOn(context, "lineTo");
const stroke = vi.spyOn(context, "stroke");

drawDebugVectors(
context,
0,
0,
0,
90,
{
heading: "#111",
steering: "#222",
steeringArcFill: "#333",
},
{ fillTurnArc: true, length: 10 }
);

const lineToCalls = lineTo.mock.calls;

expect(beginPath).toHaveBeenCalled();
expect(lineTo).toHaveBeenCalledWith(0, -10);
expect(lineToCalls[1]?.[0]).toBeCloseTo(10);
expect(lineToCalls[1]?.[1]).toBeCloseTo(0);
expect(stroke).toHaveBeenCalledTimes(2);
expect(fill).toHaveBeenCalledTimes(1);
expect(context.strokeStyle).toBe("#222");
expect(context.fillStyle).toBe("#333");
});

it("runs scheduled ticker callbacks and stop callbacks", async () => {
const ticker = new Ticker();
const scheduled = vi.fn();
Expand Down Expand Up @@ -290,9 +370,12 @@ describe("engine modules", () => {
configurable: true,
value: true,
});
userOptions.setOption("masterVolume", 5);
userOptions.setOption("musicVolume", 4);
userOptions.setOption("effectsVolume", 9);
volumeSettings = {
effects: 9,
master: 5,
music: 4,
};
dispatchSoundOptionsChanged();
const sound = new Sound("/music/main_menu.ogg", {
autoplay: false,
channel: "music",
Expand All @@ -304,7 +387,8 @@ describe("engine modules", () => {

expect(element.volume).toBeCloseTo(0.2);

userOptions.setOption("musicVolume", 2);
volumeSettings.music = 2;
dispatchSoundOptionsChanged();

expect(element.volume).toBeCloseTo(0.1);

Expand Down Expand Up @@ -355,8 +439,12 @@ describe("engine modules", () => {
configurable: true,
value: true,
});
userOptions.setOption("masterVolume", 10);
userOptions.setOption("musicVolume", 5);
volumeSettings = {
effects: 8,
master: 10,
music: 5,
};
dispatchSoundOptionsChanged();
const sound = new Sound("/music/main_menu.ogg", {
autoplay: false,
channel: "music",
Expand All @@ -370,7 +458,8 @@ describe("engine modules", () => {

sound.fadeOutAndDestroy(700);
Sound.stopAll();
userOptions.setOption("musicVolume", 2);
volumeSettings.music = 2;
dispatchSoundOptionsChanged();

expect(element.volume).toBeCloseTo(0.5);
});
Expand All @@ -395,6 +484,35 @@ describe("engine modules", () => {
expect(play).toHaveBeenCalledTimes(1);
});

it("notifies configured consumers when sound playback is blocked", async () => {
Object.defineProperty(HTMLMediaElement.prototype, "canPlay", {
configurable: true,
value: true,
});
const onPlaybackBlocked = vi.fn();
const play = vi.mocked(HTMLMediaElement.prototype.play);

Sound.configure({
onPlaybackBlocked,
});
play.mockRejectedValueOnce(new DOMException("Blocked", "NotAllowedError"));

const sound = new Sound("/music/game_start.ogg", {
autoplay: false,
channel: "music",
});

sound.play();
await Promise.resolve();
sound.destroy();

expect(onPlaybackBlocked).toHaveBeenCalledTimes(1);
expect(onPlaybackBlocked).toHaveBeenCalledWith({
channel: "music",
sources: ["/music/game_start.ogg"],
});
});

it("disconnects and closes spatial audio resources on destroy", () => {
Object.defineProperty(HTMLMediaElement.prototype, "canPlay", {
configurable: true,
Expand Down
Loading
Loading