diff --git a/eslint.config.js b/eslint.config.js index f5bd538..35fa238 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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", diff --git a/package-lock.json b/package-lock.json index 460b761..ff6e59a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "time-pilot", - "version": "27.1.0", + "version": "29.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "time-pilot", - "version": "27.1.0", + "version": "29.2.0", "dependencies": { "classnames": "^2.5.1", "pg": "^8.21.0", diff --git a/package.json b/package.json index 3aef7b0..0051e47 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "time-pilot", "private": true, - "version": "27.1.0", + "version": "29.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/arcade-engine/package.json b/packages/arcade-engine/package.json new file mode 100644 index 0000000..8127019 --- /dev/null +++ b/packages/arcade-engine/package.json @@ -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" +} diff --git a/src/game/engine/Sound.ts b/packages/arcade-engine/src/Sound.ts similarity index 87% rename from src/game/engine/Sound.ts rename to packages/arcade-engine/src/Sound.ts index f5febf8..bf7f77b 100644 --- a/src/game/engine/Sound.ts +++ b/packages/arcade-engine/src/Sound.ts @@ -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; } @@ -30,8 +33,12 @@ class Sound { private static _instances = new Set(); private static _isMuted = false; private static _pausedInstances = new Set(); - 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[]; @@ -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(); @@ -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); } @@ -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 => { @@ -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(); @@ -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, sources: this._urls, }); diff --git a/src/game/engine/Ticker.ts b/packages/arcade-engine/src/Ticker.ts similarity index 99% rename from src/game/engine/Ticker.ts rename to packages/arcade-engine/src/Ticker.ts index 8a531aa..55b6600 100644 --- a/src/game/engine/Ticker.ts +++ b/packages/arcade-engine/src/Ticker.ts @@ -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 & { diff --git a/src/game/engine/__tests__/engine.test.ts b/packages/arcade-engine/src/__tests__/engine.test.ts similarity index 78% rename from src/game/engine/__tests__/engine.test.ts rename to packages/arcade-engine/src/__tests__/engine.test.ts index d2c8fa9..cbc1641 100644 --- a/src/game/engine/__tests__/engine.test.ts +++ b/packages/arcade-engine/src/__tests__/engine.test.ts @@ -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(); }); @@ -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(); @@ -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", @@ -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); @@ -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", @@ -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); }); @@ -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, diff --git a/src/game/engine/__tests__/helpers.test.ts b/packages/arcade-engine/src/__tests__/helpers.test.ts similarity index 100% rename from src/game/engine/__tests__/helpers.test.ts rename to packages/arcade-engine/src/__tests__/helpers.test.ts diff --git a/src/game/engine/arena.ts b/packages/arcade-engine/src/arena.ts similarity index 93% rename from src/game/engine/arena.ts rename to packages/arcade-engine/src/arena.ts index 67c4de4..4e830e2 100644 --- a/src/game/engine/arena.ts +++ b/packages/arcade-engine/src/arena.ts @@ -1,13 +1,12 @@ /* Converted from engine/GameArena.js (AMD) to ESM TypeScript. */ -import { assetPath } from "../asset-path"; -import palette from "../palette"; import type { AssetProgress, CircleOptions, GameArenaInstance, + GameArenaOptions, RenderTextOptions, SpriteFrame, -} from "../types"; +} from "./types"; type CanvasContext = CanvasRenderingContext2D | WebGLRenderingContext; type CanvasWithDebugGrid = HTMLCanvasElement & { @@ -35,6 +34,12 @@ type FullscreenDocument = Document & { type TextAlign = CanvasRenderingContext2D["textAlign"]; const spaceAdvanceMultiplier = 2; +const defaultArenaOptions: Required = { + debugGridColor: "#777777", + defaultTextColor: "#ffffff", + fontFamily: "sans-serif", + fontUrl: "", +}; /** * Canvas arena wrapper responsible for sizing, assets, text, sprites, and fullscreen. @@ -47,6 +52,7 @@ class GameArena implements GameArenaInstance { private _isInFullScreen = false; private _oldHeight: number; private _oldWidth: number; + private readonly _options: Required; private _styles?: HTMLStyleElement; private readonly _handleFullscreenChange = (): void => { this._isInFullScreen = this.isFullScreen(); @@ -73,8 +79,12 @@ class GameArena implements GameArenaInstance { posY = 0; width = 0; - constructor(containerElement: HTMLElement) { + constructor(containerElement: HTMLElement, options: GameArenaOptions = {}) { this._containerElement = containerElement; + this._options = { + ...defaultArenaOptions, + ...options, + }; this._canvas = document.createElement("canvas"); this.resize(); @@ -100,16 +110,19 @@ class GameArena implements GameArenaInstance { } private _init = (): void => { - this._styles = document.createElement("style"); - this._styles.innerText = - "@font-face {" + - "font-family: 'theFont';" + - `src: url('${assetPath("fonts/font.ttf")}');` + - " }"; + if (this._options.fontUrl) { + this._styles = document.createElement("style"); + this._styles.innerText = + "@font-face {" + + `font-family: '${this._options.fontFamily}';` + + `src: url('${this._options.fontUrl}');` + + " }"; + this._containerElement.appendChild(this._styles); + } + this._canvas.tabIndex = 0; this._canvas.style.outline = "none"; - this._containerElement.appendChild(this._styles); this._containerElement.appendChild(this._canvas); }; @@ -306,8 +319,8 @@ class GameArena implements GameArenaInstance { size: newOptions.size || 12, align: newOptions.align || "left", valign: newOptions.valign || "top", - color: newOptions.color || palette.text.white, - font: newOptions.font || "theFont", + color: newOptions.color || this._options.defaultTextColor, + font: newOptions.font || this._options.fontFamily, stroke: newOptions.stroke || false, strokeWidth: newOptions.strokeWidth || 1, }; @@ -468,7 +481,7 @@ class GameArena implements GameArenaInstance { context.lineTo(this.width, 0.5 + x); } - context.strokeStyle = palette.menu.disabledText; + context.strokeStyle = this._options.debugGridColor; context.stroke(); }; diff --git a/packages/arcade-engine/src/debug-vectors.ts b/packages/arcade-engine/src/debug-vectors.ts new file mode 100644 index 0000000..75e0827 --- /dev/null +++ b/packages/arcade-engine/src/debug-vectors.ts @@ -0,0 +1,97 @@ +import type { Heading } from "./types"; + +export interface DebugVectorOptions { + fillTurnArc?: boolean; + length?: number; +} + +export interface DebugVectorColors { + heading: string; + steering: string; + steeringArcFill: string; +} + +const degreesToRadians = (degrees: number): number => degrees * (Math.PI / 180); + +const normalizeHeading = (heading: Heading): Heading => + ((heading % 360) + 360) % 360; + +const getHeadingDelta = (from: Heading, to: Heading): number => { + return ((normalizeHeading(to) - normalizeHeading(from) + 540) % 360) - 180; +}; + +const getVectorEnd = ( + posX: number, + posY: number, + heading: Heading, + length: number +): { x: number; y: number } => { + const radians = degreesToRadians(heading); + + return { + x: posX + Math.sin(radians) * length, + y: posY - Math.cos(radians) * length, + }; +}; + +const getCanvasArcAngle = (heading: Heading): number => { + return degreesToRadians(normalizeHeading(heading) - 90); +}; + +const drawVectorLine = ( + context: CanvasRenderingContext2D, + posX: number, + posY: number, + heading: Heading, + length: number, + color: string +): void => { + const end = getVectorEnd(posX, posY, heading, length); + + context.beginPath(); + context.moveTo(posX, posY); + context.lineTo(end.x, end.y); + context.strokeStyle = color; + context.stroke(); +}; + +/** + * Draws heading and steering vectors for debug overlays. + */ +export const drawDebugVectors = ( + context: CanvasRenderingContext2D, + posX: number, + posY: number, + heading: Heading, + steeringHeading: Heading, + colors: DebugVectorColors, + options: DebugVectorOptions = {} +): void => { + const length = options.length ?? 34; + + context.save(); + context.lineWidth = 2; + + drawVectorLine(context, posX, posY, heading, length, colors.heading); + drawVectorLine(context, posX, posY, steeringHeading, length, colors.steering); + + const delta = getHeadingDelta(heading, steeringHeading); + + if (options.fillTurnArc && delta !== 0) { + context.beginPath(); + context.moveTo(posX, posY); + context.arc( + posX, + posY, + length, + getCanvasArcAngle(heading), + getCanvasArcAngle(steeringHeading), + delta < 0 + ); + context.closePath(); + context.fillStyle = colors.steeringArcFill; + context.fill(); + } + + context.restore(); +}; diff --git a/src/game/engine/helpers.ts b/packages/arcade-engine/src/helpers.ts similarity index 99% rename from src/game/engine/helpers.ts rename to packages/arcade-engine/src/helpers.ts index 96beec1..d77d893 100644 --- a/src/game/engine/helpers.ts +++ b/packages/arcade-engine/src/helpers.ts @@ -1,5 +1,5 @@ /* Converted from engine/helpers.js (AMD) to ESM TypeScript. */ -import type { Coordinates, Heading, PositionedRadius } from "../types"; +import type { Coordinates, Heading, PositionedRadius } from "./types"; interface LegacyEventTarget extends EventTarget { attachEvent?: (eventName: string, callback: EventListener) => void; diff --git a/packages/arcade-engine/src/index.ts b/packages/arcade-engine/src/index.ts new file mode 100644 index 0000000..1c7fe7d --- /dev/null +++ b/packages/arcade-engine/src/index.ts @@ -0,0 +1,32 @@ +export { drawDebugVectors } from "./debug-vectors"; +export { default as GameArena } from "./arena"; +export { default as helpers } from "./helpers"; +export { default as Sound } from "./Sound"; +export { default as Ticker } from "./Ticker"; +export { + getScaledViewportLimit, + getViewportAreaScale, + getViewportPaddedRadius, + getViewportRadius, +} from "./viewport"; +export type { DebugVectorColors, DebugVectorOptions } from "./debug-vectors"; +export type { + AssetProgress, + CircleOptions, + Coordinates, + GameArenaInstance, + GameArenaOptions, + Heading, + PositionedRadius, + RenderTextOptions, + SoundChannel, + SoundEngineConfiguration, + SoundPlaybackBlockedDetails, + SpriteFrame, + TickerInstance, +} from "./types"; +export type { + ViewportAreaScaleOptions, + ViewportDimensions, + ViewportRadiusOptions, +} from "./viewport"; diff --git a/packages/arcade-engine/src/types.ts b/packages/arcade-engine/src/types.ts new file mode 100644 index 0000000..933a94b --- /dev/null +++ b/packages/arcade-engine/src/types.ts @@ -0,0 +1,132 @@ +/** + * Heading in degrees, where 0 points up the screen. + */ +export type Heading = number; + +/** + * Two-dimensional position in game coordinates. + */ +export interface Coordinates { + posX: number; + posY: number; +} + +/** + * Position plus collision/render radius. + */ +export interface PositionedRadius extends Coordinates { + radius: number; +} + +/** + * Source and destination data for rendering a sprite frame. + */ +export interface SpriteFrame extends Coordinates { + flipY?: boolean; + frameWidth: number; + frameHeight: number; + frameX: number; + frameY: number; + renderHeight?: number; + renderWidth?: number; +} + +/** + * Options for canvas-rendered text. + */ +export interface RenderTextOptions { + align?: CanvasTextAlign; + valign?: CanvasTextBaseline; + size?: number; + color?: string; + font?: string; + stroke?: string | false; + strokeWidth?: number; +} + +/** + * Options for drawing debug or gameplay circles. + */ +export interface CircleOptions { + backgroundColor?: string; + borderColor?: string | false; + borderWidth?: number; +} + +/** + * Asset preload progress counts. + */ +export interface AssetProgress { + loaded: number; + remaining: number; +} + +export interface GameArenaOptions { + debugGridColor?: string; + defaultTextColor?: string; + fontFamily?: string; + fontUrl?: string; +} + +export interface GameArenaInstance extends Coordinates { + width: number; + height: number; + updatePosition: (posX: number, posY: number) => void; + resize: (width?: number, height?: number) => void; + getContext: ( + dimensions?: "2D" | "2d" | "3D" | "3d" | 2 | 3 + ) => CanvasRenderingContext2D | WebGLRenderingContext; + enterFullScreen: () => void; + exitFullScreen: () => void; + isFullScreen: () => boolean; + isFullScreenLocked: () => boolean; + canToggleFullScreen: () => boolean; + toggleFullScreen: () => void; + setBackgroundColor: (color: string) => void; + clear: () => void; + registerAssets: (assets: string | string[]) => void; + preloadAssets: (callback?: (progress: AssetProgress) => void) => void; + renderText: ( + message: string | number, + startPosX?: number, + startPosY?: number, + options?: RenderTextOptions + ) => void; + renderSprite: (sprite: CanvasImageSource, spriteData: SpriteFrame) => void; + drawCircle: ( + posX: number, + posY: number, + radius: number, + options?: CircleOptions + ) => void; + drawDebugGrid: (widthSpace?: number, heightSpace?: number) => void; + getElement: () => HTMLCanvasElement; + destroy?: () => void; +} + +export interface TickerInstance { + isRunning: boolean; + start: () => void; + stop: (callback?: () => void) => void; + setFixedStepFps: (fps: number) => void; + setFps: (fps?: number) => void; + addSchedule: (callback: (frame: number) => void, nthFrame: number) => number; + removeSchedule: (eventId: number) => boolean; + clearSchedule: () => void; + clearTicks: () => boolean; + getTicks: () => number; +} + +export type SoundChannel = "effects" | "music"; + +export interface SoundPlaybackBlockedDetails { + channel: SoundChannel; + sources: string[]; +} + +export interface SoundEngineConfiguration { + getVolume?: (channel: SoundChannel) => number; + onPlaybackBlocked?: (details: SoundPlaybackBlockedDetails) => void; + volumeChangeEventName?: string; + volumeChangeEventTarget?: EventTarget; +} diff --git a/packages/arcade-engine/src/viewport.ts b/packages/arcade-engine/src/viewport.ts new file mode 100644 index 0000000..64ece13 --- /dev/null +++ b/packages/arcade-engine/src/viewport.ts @@ -0,0 +1,66 @@ +export interface ViewportDimensions { + height: number; + width: number; +} + +export interface ViewportRadiusOptions { + minRadius?: number; + padding?: number; +} + +export interface ViewportAreaScaleOptions { + minScale?: number; + referenceHeight?: number; + referenceWidth?: number; +} + +const defaultReferenceWidth = 800; +const defaultReferenceHeight = 600; + +/** + * Calculates the radius needed to cover the current viewport from its center. + */ +export const getViewportRadius = (viewport: ViewportDimensions): number => { + return Math.hypot(viewport.width, viewport.height) / 2; +}; + +/** + * Calculates a radius around the viewport, with optional padding and floor. + */ +export const getViewportPaddedRadius = ( + viewport: ViewportDimensions, + options: ViewportRadiusOptions = {} +): number => { + return Math.max( + options.minRadius ?? 0, + getViewportRadius(viewport) + (options.padding ?? 0) + ); +}; + +/** + * Scales budgets based on viewport area relative to a reference viewport. + */ +export const getViewportAreaScale = ( + viewport: ViewportDimensions, + options: ViewportAreaScaleOptions = {} +): number => { + const referenceWidth = options.referenceWidth ?? defaultReferenceWidth; + const referenceHeight = options.referenceHeight ?? defaultReferenceHeight; + const minScale = options.minScale ?? 1; + + return Math.max( + minScale, + (viewport.width * viewport.height) / (referenceWidth * referenceHeight) + ); +}; + +/** + * Applies viewport-area scaling to an entity or effect budget. + */ +export const getScaledViewportLimit = ( + baseLimit: number, + viewport: ViewportDimensions, + options: ViewportAreaScaleOptions = {} +): number => { + return Math.ceil(baseLimit * getViewportAreaScale(viewport, options)); +}; diff --git a/packages/arcade-engine/tsconfig.json b/packages/arcade-engine/tsconfig.json new file mode 100644 index 0000000..1dac9cf --- /dev/null +++ b/packages/arcade-engine/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/src/game/__tests__/module-imports.test.ts b/src/game/__tests__/module-imports.test.ts index 67fbc7c..97dd641 100644 --- a/src/game/__tests__/module-imports.test.ts +++ b/src/game/__tests__/module-imports.test.ts @@ -21,6 +21,21 @@ import Prop from "../prop"; import PropFactory from "../prop-factory"; import userOptions from "../user-options"; +const engineSourceFiles = import.meta.glob( + "../../../packages/arcade-engine/src/**/*.ts", + { + eager: true, + import: "default", + query: "?raw", + } +); + +const getStaticImportSpecifiers = (source: string): string[] => + Array.from( + source.matchAll(/\bimport\s+(?:[^"';]+?\s+from\s+)?["']([^"']+)["']/g), + (match) => match[1] + ); + describe("game module imports", () => { it("loads every game module", () => { expect(Bonus).toBeTypeOf("function"); @@ -95,4 +110,16 @@ describe("game module imports", () => { expect(availableLanguages).toEqual(["en", "fr", "es", "de", "it", "nl", "ro"]); expect(getLanguageName("es")).toBe("Espanol"); }); + + it("keeps production engine modules independent from game modules", () => { + const invalidImports = Object.entries(engineSourceFiles) + .filter(([file]) => !file.includes("/__tests__/")) + .flatMap(([file, source]) => + getStaticImportSpecifiers(source) + .filter((specifier) => specifier.startsWith("../")) + .map((specifier) => `${file} -> ${specifier}`) + ); + + expect(invalidImports).toEqual([]); + }); }); diff --git a/src/game/bonus.ts b/src/game/bonus.ts index de1d5f2..cb926bd 100644 --- a/src/game/bonus.ts +++ b/src/game/bonus.ts @@ -1,6 +1,6 @@ /* Converted from TimePilot.Bonus.js (AMD) to ESM TypeScript. */ import { levels, scoring } from "./constants"; -import helpers from "./engine/helpers"; +import { helpers } from "@time-pilot/arcade-engine"; import palette from "./palette"; import userOptions from "./user-options"; import { getDespawnRadius } from "./viewport"; diff --git a/src/game/bullet-factory.ts b/src/game/bullet-factory.ts index 85515ed..fcdc1a0 100644 --- a/src/game/bullet-factory.ts +++ b/src/game/bullet-factory.ts @@ -1,7 +1,7 @@ /* Converted from TimePilot.BulletFactory.js (AMD) to ESM TypeScript. */ import Bullet from "./bullet"; import { player } from "./constants"; -import SoundEngine from "./engine/Sound"; +import { Sound as SoundEngine } from "@time-pilot/arcade-engine"; import type { BulletData, BulletFactoryInstance, diff --git a/src/game/bullet.ts b/src/game/bullet.ts index e716b15..6d1514c 100644 --- a/src/game/bullet.ts +++ b/src/game/bullet.ts @@ -1,8 +1,7 @@ /* Converted from TimePilot.Bullet.js (AMD) to ESM TypeScript. */ import userOptions from "./user-options"; import { drawDebugVectors } from "./debug-vectors"; -import SoundEngine from "./engine/Sound"; -import helpers from "./engine/helpers"; +import { helpers, Sound as SoundEngine } from "@time-pilot/arcade-engine"; import palette from "./palette"; import { getDespawnRadius } from "./viewport"; import type { diff --git a/src/game/controller/gamepad.ts b/src/game/controller/gamepad.ts index e43b6f5..f2b935a 100644 --- a/src/game/controller/gamepad.ts +++ b/src/game/controller/gamepad.ts @@ -1,5 +1,5 @@ /* Converted from TimePilot.Controller.Gamepad.js (AMD) to ESM TypeScript. */ -import helpers from "../engine/helpers"; +import { helpers } from "@time-pilot/arcade-engine"; import type { ControlInputName, ControlInputState, diff --git a/src/game/controller/keyboard1.ts b/src/game/controller/keyboard1.ts index 3b8161c..eaa2786 100644 --- a/src/game/controller/keyboard1.ts +++ b/src/game/controller/keyboard1.ts @@ -1,5 +1,5 @@ /* Converted from TimePilot.Controller.Keyboard1.js (AMD) to ESM TypeScript. */ -import helpers from "../engine/helpers"; +import { helpers } from "@time-pilot/arcade-engine"; import userOptions from "../user-options"; import type { ControlInputName, diff --git a/src/game/controller/keyboard2.ts b/src/game/controller/keyboard2.ts index a38624f..879464e 100644 --- a/src/game/controller/keyboard2.ts +++ b/src/game/controller/keyboard2.ts @@ -1,5 +1,5 @@ /* Converted from TimePilot.Controller.Keyboard2.js (AMD) to ESM TypeScript. */ -import helpers from "../engine/helpers"; +import { helpers } from "@time-pilot/arcade-engine"; import userOptions from "../user-options"; import type { ControlInputName, diff --git a/src/game/controller/touch.ts b/src/game/controller/touch.ts index ad7e1c6..04df502 100644 --- a/src/game/controller/touch.ts +++ b/src/game/controller/touch.ts @@ -1,4 +1,4 @@ -import helpers from "../engine/helpers"; +import { helpers } from "@time-pilot/arcade-engine"; import { clampZoomPercent, zoomStepPercent } from "../ui-scale"; import type { ControlInputName, diff --git a/src/game/debug-vectors.ts b/src/game/debug-vectors.ts index baac578..03205e4 100644 --- a/src/game/debug-vectors.ts +++ b/src/game/debug-vectors.ts @@ -1,4 +1,5 @@ import palette from "./palette"; +import { drawDebugVectors as drawEngineDebugVectors } from "@time-pilot/arcade-engine"; import type { Heading } from "./types"; interface DebugVectorOptions { @@ -6,49 +7,6 @@ interface DebugVectorOptions { length?: number; } -const degreesToRadians = (degrees: number): number => degrees * (Math.PI / 180); - -const normalizeHeading = (heading: Heading): Heading => ((heading % 360) + 360) % 360; - -const getHeadingDelta = (from: Heading, to: Heading): number => { - return ((normalizeHeading(to) - normalizeHeading(from) + 540) % 360) - 180; -}; - -const getVectorEnd = ( - posX: number, - posY: number, - heading: Heading, - length: number -): { x: number; y: number } => { - const radians = degreesToRadians(heading); - - return { - x: posX + Math.sin(radians) * length, - y: posY - Math.cos(radians) * length, - }; -}; - -const getCanvasArcAngle = (heading: Heading): number => { - return degreesToRadians(normalizeHeading(heading) - 90); -}; - -const drawVectorLine = ( - context: CanvasRenderingContext2D, - posX: number, - posY: number, - heading: Heading, - length: number, - color: string -): void => { - const end = getVectorEnd(posX, posY, heading, length); - - context.beginPath(); - context.moveTo(posX, posY); - context.lineTo(end.x, end.y); - context.strokeStyle = color; - context.stroke(); -}; - /** * Draws heading and steering vectors for debug overlays. * @@ -67,45 +25,17 @@ export const drawDebugVectors = ( steeringHeading: Heading, options: DebugVectorOptions = {} ): void => { - const length = options.length ?? 34; - - context.save(); - context.lineWidth = 2; - - drawVectorLine( + drawEngineDebugVectors( context, posX, posY, heading, - length, - palette.debug.headingVector - ); - drawVectorLine( - context, - posX, - posY, steeringHeading, - length, - palette.debug.steeringVector + { + heading: palette.debug.headingVector, + steering: palette.debug.steeringVector, + steeringArcFill: palette.debug.steeringArcFill, + }, + options ); - - const delta = getHeadingDelta(heading, steeringHeading); - - if (options.fillTurnArc && delta !== 0) { - context.beginPath(); - context.moveTo(posX, posY); - context.arc( - posX, - posY, - length, - getCanvasArcAngle(heading), - getCanvasArcAngle(steeringHeading), - delta < 0 - ); - context.closePath(); - context.fillStyle = palette.debug.steeringArcFill; - context.fill(); - } - - context.restore(); }; diff --git a/src/game/enemy.ts b/src/game/enemy.ts index 4b702ab..f476c20 100644 --- a/src/game/enemy.ts +++ b/src/game/enemy.ts @@ -2,8 +2,7 @@ import { levels, scoring } from "./constants"; import userOptions from "./user-options"; import { drawDebugVectors } from "./debug-vectors"; -import SoundEngine from "./engine/Sound"; -import helpers from "./engine/helpers"; +import { helpers, Sound as SoundEngine } from "@time-pilot/arcade-engine"; import palette from "./palette"; import { getDespawnRadius } from "./viewport"; import type { diff --git a/src/game/index.ts b/src/game/index.ts index 628c05f..80154e6 100644 --- a/src/game/index.ts +++ b/src/game/index.ts @@ -10,9 +10,11 @@ import Keyboard2 from "./controller/keyboard2"; import Mouse from "./controller/mouse"; import Touch from "./controller/touch"; import EnemyFactory from "./enemy-factory"; -import GameArena from "./engine/arena"; -import SoundEngine from "./engine/Sound"; -import Ticker from "./engine/Ticker"; +import { + GameArena, + Sound as SoundEngine, + Ticker, +} from "@time-pilot/arcade-engine"; import { gameTickRate } from "./game-timing"; import { getHighScores, @@ -26,6 +28,7 @@ import Hud from "./hud"; import i18n from "./i18n"; import { logger } from "./logger"; import Menus from "./menus"; +import palette from "./palette"; import Player from "./player"; import Preroll from "./preroll"; import PropFactory from "./prop-factory"; @@ -112,6 +115,20 @@ const playerRotationStep = 360 / player.rotationFrameCount; const gameSessionStorageKey = "timePilot.gameSession"; const gameSessionSnapshotVersion = 1; +SoundEngine.configure({ + getVolume: (channel) => { + const channelVolume = + channel === "music" ? userOptions.musicVolume : userOptions.effectsVolume; + + return (userOptions.masterVolume / 10) * (channelVolume / 10); + }, + onPlaybackBlocked: (details) => { + logger.warning("Audio playback was blocked", details); + }, + volumeChangeEventName: "timePilot:userOptionsChanged", + volumeChangeEventTarget: typeof window !== "undefined" ? window : undefined, +}); + type HighScoreRunReceipt = Awaited>; type DemoProgressSnapshot = { @@ -473,7 +490,12 @@ export class TimePilot { this.context._levelIntroUntilTick = 0; this.context._timeWarpTransition = undefined; this.context._nextParachuteScore = scoring.parachute.min; - this.context._gameArena = new GameArena(this.container); + this.context._gameArena = new GameArena(this.container, { + debugGridColor: palette.menu.disabledText, + defaultTextColor: palette.text.white, + fontFamily: "theFont", + fontUrl: assetPath("fonts/font.ttf"), + }); this.context._renderTicker = new Ticker({ fps: this.getRenderFpsCap(), }); diff --git a/src/game/player.ts b/src/game/player.ts index 3b312b1..a446c4a 100644 --- a/src/game/player.ts +++ b/src/game/player.ts @@ -2,8 +2,7 @@ import { levels, player, scoring, sounds } from "./constants"; import userOptions from "./user-options"; import { drawDebugVectors } from "./debug-vectors"; -import SoundEngine from "./engine/Sound"; -import helpers from "./engine/helpers"; +import { helpers, Sound as SoundEngine } from "@time-pilot/arcade-engine"; import palette from "./palette"; import type { BulletFactoryInstance, diff --git a/src/game/prop.ts b/src/game/prop.ts index 93ae777..0436d3e 100644 --- a/src/game/prop.ts +++ b/src/game/prop.ts @@ -1,6 +1,6 @@ /* Converted from TimePilot.Prop.js (AMD) to ESM TypeScript. */ import { levels } from "./constants"; -import helpers from "./engine/helpers"; +import { helpers } from "@time-pilot/arcade-engine"; import { getDespawnRadius } from "./viewport"; import type { GameArenaInstance, diff --git a/src/game/systems/collision.ts b/src/game/systems/collision.ts index b9681f7..6c8386e 100644 --- a/src/game/systems/collision.ts +++ b/src/game/systems/collision.ts @@ -1,5 +1,5 @@ import { levels, player } from "../constants"; -import helpers from "../engine/helpers"; +import { helpers } from "@time-pilot/arcade-engine"; import type { BulletData, BulletInstance, diff --git a/src/game/systems/spawning.ts b/src/game/systems/spawning.ts index ab7c40b..7c5a48e 100644 --- a/src/game/systems/spawning.ts +++ b/src/game/systems/spawning.ts @@ -1,6 +1,5 @@ import { levels, limits, sounds } from "../constants"; -import SoundEngine from "../engine/Sound"; -import helpers from "../engine/helpers"; +import { helpers, Sound as SoundEngine } from "@time-pilot/arcade-engine"; import type { Coordinates, EnemyData, diff --git a/src/game/types.ts b/src/game/types.ts index faaab1a..99ae43f 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -1,65 +1,31 @@ -/** - * Heading in degrees, where 0 points up the screen. - */ -export type Heading = number; - -/** - * Two-dimensional position in game coordinates. - */ -export interface Coordinates { - posX: number; - posY: number; -} - -/** - * Position plus collision/render radius. - */ -export interface PositionedRadius extends Coordinates { - radius: number; -} - -/** - * Source and destination data for rendering a sprite frame. - */ -export interface SpriteFrame extends Coordinates { - flipY?: boolean; - frameWidth: number; - frameHeight: number; - frameX: number; - frameY: number; - renderHeight?: number; - renderWidth?: number; -} - -/** - * Options for canvas-rendered text. - */ -export interface RenderTextOptions { - align?: CanvasTextAlign; - valign?: CanvasTextBaseline; - size?: number; - color?: string; - font?: string; - stroke?: string | false; - strokeWidth?: number; -} - -/** - * Options for drawing debug or gameplay circles. - */ -export interface CircleOptions { - backgroundColor?: string; - borderColor?: string | false; - borderWidth?: number; -} - -/** - * Asset preload progress counts. - */ -export interface AssetProgress { - loaded: number; - remaining: number; -} +import type { + Coordinates, + GameArenaInstance, + Heading, + TickerInstance, +} from "@time-pilot/arcade-engine"; +export type { + AssetProgress, + CircleOptions, + Coordinates, + GameArenaInstance, + GameArenaOptions, + Heading, + PositionedRadius, + RenderTextOptions, + SoundChannel, + SoundEngineConfiguration, + SoundPlaybackBlockedDetails, + SpriteFrame, + TickerInstance, +} from "@time-pilot/arcade-engine"; +export type { + DebugVectorColors, + DebugVectorOptions, + ViewportAreaScaleOptions, + ViewportDimensions, + ViewportRadiusOptions, +} from "@time-pilot/arcade-engine"; /** * Mutable player state stored in the game data context. @@ -200,55 +166,6 @@ export interface MenuPointerData extends Coordinates { type: "click" | "drag" | "move" | "press" | "release" | "wheel"; } -export interface GameArenaInstance extends Coordinates { - width: number; - height: number; - updatePosition: (posX: number, posY: number) => void; - resize: (width?: number, height?: number) => void; - getContext: ( - dimensions?: "2D" | "2d" | "3D" | "3d" | 2 | 3 - ) => CanvasRenderingContext2D | WebGLRenderingContext; - enterFullScreen: () => void; - exitFullScreen: () => void; - isFullScreen: () => boolean; - isFullScreenLocked: () => boolean; - canToggleFullScreen: () => boolean; - toggleFullScreen: () => void; - setBackgroundColor: (color: string) => void; - clear: () => void; - registerAssets: (assets: string | string[]) => void; - preloadAssets: (callback?: (progress: AssetProgress) => void) => void; - renderText: ( - message: string | number, - startPosX?: number, - startPosY?: number, - options?: RenderTextOptions - ) => void; - renderSprite: (sprite: CanvasImageSource, spriteData: SpriteFrame) => void; - drawCircle: ( - posX: number, - posY: number, - radius: number, - options?: CircleOptions - ) => void; - drawDebugGrid: (widthSpace?: number, heightSpace?: number) => void; - getElement: () => HTMLCanvasElement; - destroy?: () => void; -} - -export interface TickerInstance { - isRunning: boolean; - start: () => void; - stop: (callback?: () => void) => void; - setFixedStepFps: (fps: number) => void; - setFps: (fps?: number) => void; - addSchedule: (callback: (frame: number) => void, nthFrame: number) => number; - removeSchedule: (eventId: number) => boolean; - clearSchedule: () => void; - clearTicks: () => boolean; - getTicks: () => number; -} - export interface BulletInstance { removeMe: boolean; explode: () => void; diff --git a/src/game/viewport.ts b/src/game/viewport.ts index 343d979..47ca6f1 100644 --- a/src/game/viewport.ts +++ b/src/game/viewport.ts @@ -1,18 +1,15 @@ import { limits } from "./constants"; -import type { GameArenaInstance } from "./types"; +import { + getScaledViewportLimit, + getViewportAreaScale as getEngineViewportAreaScale, + getViewportPaddedRadius, +} from "@time-pilot/arcade-engine"; +import type { ViewportDimensions } from "./types"; const spawnPadding = 96; const despawnPadding = 160; -/** - * Calculates the radius needed to cover the current viewport from its center. - * - * @param gameArena - Arena dimensions. - * @returns Half of the viewport diagonal. - */ -export const getViewportRadius = (gameArena: Pick): number => { - return Math.hypot(gameArena.width, gameArena.height) / 2; -}; +export { getViewportRadius } from "@time-pilot/arcade-engine"; /** * Calculates the distance from center where entities can spawn safely off-screen. @@ -20,11 +17,11 @@ export const getViewportRadius = (gameArena: Pick): number => { - return Math.max( - limits.spawningRadius, - getViewportRadius(gameArena) + spawnPadding - ); +export const getSpawnRadius = (gameArena: ViewportDimensions): number => { + return getViewportPaddedRadius(gameArena, { + minRadius: limits.spawningRadius, + padding: spawnPadding, + }); }; /** @@ -33,9 +30,8 @@ export const getSpawnRadius = (gameArena: Pick): number => { - return Math.max(1, (gameArena.width * gameArena.height) / (800 * 600)); -}; +export const getViewportAreaScale = (gameArena: ViewportDimensions): number => + getEngineViewportAreaScale(gameArena); /** * Applies viewport-area scaling to an entity limit. @@ -44,9 +40,10 @@ export const getViewportAreaScale = (gameArena: Pick): number => { - return Math.ceil(baseLimit * getViewportAreaScale(gameArena)); -}; +export const getScaledEntityLimit = ( + baseLimit: number, + gameArena: ViewportDimensions +): number => getScaledViewportLimit(baseLimit, gameArena); /** * Calculates the radius beyond which entities should be removed. @@ -54,9 +51,11 @@ export const getScaledEntityLimit = (baseLimit: number, gameArena: Pick): number => { - return Math.max( - limits.despawnRadius, - getViewportRadius(gameArena) + despawnPadding - ); +export const getDespawnRadius = ( + gameArena: ViewportDimensions +): number => { + return getViewportPaddedRadius(gameArena, { + minRadius: limits.despawnRadius, + padding: despawnPadding, + }); }; diff --git a/src/stories/Audio.stories.tsx b/src/stories/Audio.stories.tsx index 51a1e2f..1f4d885 100644 --- a/src/stories/Audio.stories.tsx +++ b/src/stories/Audio.stories.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Sound as SoundEngine } from "@time-pilot/arcade-engine"; import { assetPath } from "../game/asset-path"; -import SoundEngine from "../game/engine/Sound"; import palette from "../game/palette"; import "./storybook.scss"; diff --git a/src/stories/Palette.stories.tsx b/src/stories/Palette.stories.tsx index 2495e21..86010c4 100644 --- a/src/stories/Palette.stories.tsx +++ b/src/stories/Palette.stories.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import helpers from "../game/engine/helpers"; +import { helpers } from "@time-pilot/arcade-engine"; import palette from "../game/palette"; import "./storybook.scss"; diff --git a/src/stories/Ticker.stories.tsx b/src/stories/Ticker.stories.tsx index 816c2bc..1b9ec65 100644 --- a/src/stories/Ticker.stories.tsx +++ b/src/stories/Ticker.stories.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import Ticker from "../game/engine/Ticker"; +import { Ticker } from "@time-pilot/arcade-engine"; import palette from "../game/palette"; import "./storybook.scss"; diff --git a/tsconfig.json b/tsconfig.json index 907cc4f..5217fce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,11 @@ "allowJs": false, "jsx": "react-jsx", "noEmit": true, + "baseUrl": ".", + "paths": { + "@time-pilot/arcade-engine": ["packages/arcade-engine/src/index.ts"] + }, "types": ["vite/client"] }, - "include": ["src", ".storybook"] + "include": ["src", "packages", ".storybook"] } diff --git a/vite.config.ts b/vite.config.ts index 8a700d2..93648e1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -167,6 +167,14 @@ export default defineConfig({ }, }, }, + resolve: { + alias: { + "@time-pilot/arcade-engine": path.resolve( + dirname, + "packages/arcade-engine/src/index.ts" + ), + }, + }, server: { host: "0.0.0.0", open: true,