From 9edcc649845277638564fc61dcc5294e83e0b363 Mon Sep 17 00:00:00 2001 From: Rob Taylor <373278+manix84@users.noreply.github.com> Date: Sat, 30 May 2026 23:26:01 +0100 Subject: [PATCH 01/10] Refactor game timing to use fixed-step simulation and update related components --- README.md | 4 +- WHATSNEW.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/App.tsx | 2 +- src/components/UpdateOverlay.tsx | 4 +- src/game/__tests__/achievements.test.ts | 4 +- src/game/achievements.ts | 12 +-- src/game/engine/Ticker.ts | 93 ++++++++++++++++++++---- src/game/engine/__tests__/engine.test.ts | 32 ++++++++ src/game/game-timing.ts | 8 +- src/game/index.ts | 17 +++-- 12 files changed, 145 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 7549c1a..b9b8629 100644 --- a/README.md +++ b/README.md @@ -368,8 +368,8 @@ The current design keeps React out of the game loop. This is deliberate. - Entities, factories, controllers, HUD, and engine wrappers are class-based modules. - Collision, spawning, and frame rendering live in dedicated systems under `src/game/systems`. - Entities and factories receive explicit context instead of reading a global singleton. -- Simulation uses a fixed-step ticker at 50fps for movement, spawning, collisions, cleanup, and player actions. -- Rendering uses a separate animation-frame ticker to paint the latest entity locations and orientations as often as the browser can display them. +- Simulation uses a fixed-step 50Hz ticker for movement, spawning, collisions, cleanup, and player actions. +- Rendering uses a separate FPS-capped animation-frame ticker to paint the latest entity locations and orientations without changing gameplay speed. - Game rendering applies pixelated POV scaling separately from HUD and menu UI scaling. - Rendering stays canvas-based for predictable paint ordering and frame-by-frame control. - Public game utilities, engine entry points, systems, controllers, and React diff --git a/WHATSNEW.md b/WHATSNEW.md index 43f7d52..9f2d05e 100644 --- a/WHATSNEW.md +++ b/WHATSNEW.md @@ -29,7 +29,7 @@ - Added `useTimePilot` as the React lifecycle bridge. - Converted the remaining prototype-style runtime modules into class-based entities, factories, controllers, engine wrappers, HUD, and menu systems. - Split collision handling, entity spawning, and frame rendering into dedicated systems. -- Separated simulation ticking from rendering: game-state calculations run at 50fps, while canvas rendering runs every animation frame. +- Separated simulation ticking from rendering: game-state calculations run on a 50Hz fixed step, while canvas rendering is independently capped. - Removed the old 50,000-tick gameplay pause failsafe so long sessions can keep running normally. - Added a page-lifecycle session snapshot so interrupted player runs can be diff --git a/package-lock.json b/package-lock.json index 0478e92..ee48537 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "time-pilot", - "version": "24.1.0", + "version": "25.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "time-pilot", - "version": "24.1.0", + "version": "25.0.0", "dependencies": { "classnames": "^2.5.1", "pg": "^8.21.0", diff --git a/package.json b/package.json index e3276e6..b2d38c7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "time-pilot", "private": true, - "version": "24.1.0", + "version": "25.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.tsx b/src/App.tsx index ec47394..c18245b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -91,7 +91,7 @@ const goals = [ ]; const systemUpdates = [ - "50fps simulation with separate animation-frame rendering.", + "50Hz fixed-step simulation with separately capped rendering.", "Spatial entity audio for bosses, rockets, bullets, bombs, and explosions.", "Skippable author and Time Pilot preroll before cold-start root-menu entry.", "Page-lifecycle session snapshots that restore interrupted player runs without per-frame storage writes.", diff --git a/src/components/UpdateOverlay.tsx b/src/components/UpdateOverlay.tsx index 8815919..bf65efe 100644 --- a/src/components/UpdateOverlay.tsx +++ b/src/components/UpdateOverlay.tsx @@ -28,8 +28,8 @@ type UpdateOverlayProps = { state: "updating" | "warping"; }; -const gameFps = 50; -const frameDurationMs = 1000 / gameFps; +const animationTickRate = 50; +const frameDurationMs = 1000 / animationTickRate; const playerRenderSize = 64; const statusTextOffsetY = 96; const warpRenderScale = 4; diff --git a/src/game/__tests__/achievements.test.ts b/src/game/__tests__/achievements.test.ts index 427d53a..3d4d95f 100644 --- a/src/game/__tests__/achievements.test.ts +++ b/src/game/__tests__/achievements.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import AchievementSystem, { achievementDefinitions } from "../achievements"; -import { gameFps } from "../game-timing"; +import { gameTickRate } from "../game-timing"; import { createRunStats } from "../run-stats"; import type { BonusFactoryInstance, @@ -496,7 +496,7 @@ describe("AchievementSystem", () => { achievements.onShootableProjectileDestroyed(); achievements.onLevelCompleted(5, 1, context._player.getData()); achievements.onLevelStarted(5); - ticks = 60 * 60 * gameFps; + ticks = 60 * 60 * gameTickRate; achievements.update(); expect(achievements.getUnlocked()).toEqual([]); diff --git a/src/game/achievements.ts b/src/game/achievements.ts index 71fb69b..a83316e 100644 --- a/src/game/achievements.ts +++ b/src/game/achievements.ts @@ -1,5 +1,5 @@ import { assetPath } from "./asset-path"; -import { gameFps } from "./game-timing"; +import { gameTickRate } from "./game-timing"; import { logger } from "./logger"; import type { BulletData, @@ -292,11 +292,11 @@ interface WaveState { * Local storage key for persisted achievement state. */ export const achievementStorageKey = "timePilot.achievements"; -const thisIsFineTicks = 30 * gameFps; -const notAgainTicks = 3 * gameFps; -const threeStrikesTicks = 60 * gameFps; -const oneHourTicks = 60 * 60 * gameFps; -const immediateBossTicks = 10 * gameFps; +const thisIsFineTicks = 30 * gameTickRate; +const notAgainTicks = 3 * gameTickRate; +const threeStrikesTicks = 60 * gameTickRate; +const oneHourTicks = 60 * 60 * gameTickRate; +const immediateBossTicks = 10 * gameTickRate; const nearCollisionDistance = 72; const chaoticEntityThreshold = 8; const chaoticProjectileThreshold = 3; diff --git a/src/game/engine/Ticker.ts b/src/game/engine/Ticker.ts index 896ee0e..a51dc6f 100644 --- a/src/game/engine/Ticker.ts +++ b/src/game/engine/Ticker.ts @@ -16,7 +16,9 @@ interface TickerScheduleItem { } interface TickerOptions { + fixedStepFps?: number; fps?: number; + maxCatchUpFrames?: number; } const animationWindow = window as LegacyAnimationWindow; @@ -31,8 +33,11 @@ const requestAnimationFrame = */ class Ticker implements TickerInstance { private _frame = 0; + private _accumulatedStepTime = 0; + private readonly _fixedStepInterval?: number; private readonly _frameInterval?: number; private _lastStepTime: number | null = null; + private readonly _maxCatchUpFrames: number; private _schedule: Record = {}; private _scheduleCount = 0; private killCallback?: () => void; @@ -40,7 +45,15 @@ class Ticker implements TickerInstance { isRunning = false; constructor(options: TickerOptions = {}) { + if (options.fixedStepFps && options.fps) { + throw new Error("Ticker cannot use fixedStepFps and fps together."); + } + + this._fixedStepInterval = options.fixedStepFps + ? 1000 / options.fixedStepFps + : undefined; this._frameInterval = options.fps ? 1000 / options.fps : undefined; + this._maxCatchUpFrames = options.maxCatchUpFrames ?? 10; } start = (): void => { @@ -49,6 +62,7 @@ class Ticker implements TickerInstance { } this.isRunning = true; + this._accumulatedStepTime = 0; this._lastStepTime = null; this._step(); }; @@ -65,21 +79,15 @@ class Ticker implements TickerInstance { return; } - if (!this.shouldRunFrame(timestamp)) { + const framesToRun = this.getFramesToRun(timestamp); + + if (!framesToRun) { this._step(); return; } - this._frame++; - this._lastStepTime = timestamp; - - for (const eventId in this._schedule) { - if ( - Object.prototype.hasOwnProperty.call(this._schedule, eventId) && - this._frame % this._schedule[eventId].nthFrame === 0 - ) { - this._schedule[eventId].callback(this._frame); - } + for (let frame = 0; frame < framesToRun && this.isRunning; frame++) { + this.runScheduledFrame(); } if (this.isRunning) { @@ -97,7 +105,48 @@ class Ticker implements TickerInstance { } }; - private shouldRunFrame = (timestamp: number): boolean => { + private getFramesToRun = (timestamp: number): number => { + if (this._fixedStepInterval) { + return this.getFixedStepFrameCount(timestamp); + } + + return this.shouldRunRenderFrame(timestamp) ? 1 : 0; + }; + + private getFixedStepFrameCount = (timestamp: number): number => { + if (this._lastStepTime === null) { + this._lastStepTime = timestamp; + return 0; + } + + const elapsedMs = timestamp - this._lastStepTime; + this._lastStepTime = timestamp; + + if (elapsedMs <= 0) { + return 0; + } + + this._accumulatedStepTime += elapsedMs; + + const elapsedFrames = Math.floor( + this._accumulatedStepTime / this._fixedStepInterval + ); + + if (!elapsedFrames) { + return 0; + } + + const framesToRun = Math.min(elapsedFrames, this._maxCatchUpFrames); + this._accumulatedStepTime -= framesToRun * this._fixedStepInterval; + + if (elapsedFrames > this._maxCatchUpFrames) { + this._accumulatedStepTime %= this._fixedStepInterval; + } + + return framesToRun; + }; + + private shouldRunRenderFrame = (timestamp: number): boolean => { if (!this._frameInterval) { return true; } @@ -107,7 +156,25 @@ class Ticker implements TickerInstance { return false; } - return timestamp - this._lastStepTime >= this._frameInterval; + if (timestamp - this._lastStepTime < this._frameInterval) { + return false; + } + + this._lastStepTime = timestamp; + return true; + }; + + private runScheduledFrame = (): void => { + this._frame++; + + for (const eventId in this._schedule) { + if ( + Object.prototype.hasOwnProperty.call(this._schedule, eventId) && + this._frame % this._schedule[eventId].nthFrame === 0 + ) { + this._schedule[eventId].callback(this._frame); + } + } }; addSchedule = (callback: TickerScheduleCallback, nthFrame: number): number => { diff --git a/src/game/engine/__tests__/engine.test.ts b/src/game/engine/__tests__/engine.test.ts index 7845cf2..d2c8fa9 100644 --- a/src/game/engine/__tests__/engine.test.ts +++ b/src/game/engine/__tests__/engine.test.ts @@ -224,6 +224,38 @@ describe("engine modules", () => { ticker.stop(); }); + it("runs fixed-step schedules at a stable simulation rate across render frames", () => { + const animationFrames: FrameRequestCallback[] = []; + const requestAnimationFrameSpy = vi.mocked(window.requestAnimationFrame); + requestAnimationFrameSpy.mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + const ticker = new Ticker({ fixedStepFps: 50 }); + const scheduled = vi.fn(); + + ticker.addSchedule(scheduled, 1); + ticker.start(); + + animationFrames.shift()?.(0); + animationFrames.shift()?.(16); + expect(scheduled).not.toHaveBeenCalled(); + + animationFrames.shift()?.(40); + expect(scheduled).toHaveBeenCalledTimes(2); + expect(scheduled).toHaveBeenNthCalledWith(1, 1); + expect(scheduled).toHaveBeenNthCalledWith(2, 2); + + animationFrames.shift()?.(56); + expect(scheduled).toHaveBeenCalledTimes(2); + + animationFrames.shift()?.(60); + expect(scheduled).toHaveBeenCalledTimes(3); + expect(scheduled).toHaveBeenLastCalledWith(3); + + ticker.stop(); + }); + it("creates playable sounds", () => { const sound = new Sound("/sounds/player/bullet.ogg", { autoplay: false }); diff --git a/src/game/game-timing.ts b/src/game/game-timing.ts index aa2e9b5..6172f58 100644 --- a/src/game/game-timing.ts +++ b/src/game/game-timing.ts @@ -1,4 +1,10 @@ /** * Fixed simulation tick rate used by gameplay systems. */ -export const gameFps = 50; +export const gameTickRate = 50; + +/** + * Render frame cap. This controls canvas paint cadence only, not simulation + * speed. + */ +export const renderFps = 60; diff --git a/src/game/index.ts b/src/game/index.ts index 219a515..000d0b4 100644 --- a/src/game/index.ts +++ b/src/game/index.ts @@ -13,7 +13,7 @@ import EnemyFactory from "./enemy-factory"; import GameArena from "./engine/arena"; import SoundEngine from "./engine/Sound"; import Ticker from "./engine/Ticker"; -import { gameFps } from "./game-timing"; +import { gameTickRate, renderFps } from "./game-timing"; import { getHighScores, getHighScoreThresholds, @@ -81,19 +81,19 @@ export const LEVEL_INTRO_DURATION_MS = 5000; export const TIME_WARP_DELAY_MS = timeWarpDelayMs; const demoLevelDurationFrames = Math.max( 1, - Math.round((DEMO_LEVEL_DURATION_MS / 1000) * gameFps) + Math.round((DEMO_LEVEL_DURATION_MS / 1000) * gameTickRate) ); const demoLevelFadeFrames = Math.max( 1, - Math.round((DEMO_LEVEL_FADE_MS / 1000) * gameFps) + Math.round((DEMO_LEVEL_FADE_MS / 1000) * gameTickRate) ); const levelIntroDurationFrames = Math.max( 1, - Math.round((LEVEL_INTRO_DURATION_MS / 1000) * gameFps) + Math.round((LEVEL_INTRO_DURATION_MS / 1000) * gameTickRate) ); const timeWarpDelayFrames = Math.max( 1, - Math.round((TIME_WARP_DELAY_MS / 1000) * gameFps) + Math.round((TIME_WARP_DELAY_MS / 1000) * gameTickRate) ); const musicFadeDurationMs = 700; const levelStartMusicFallbackMs = 12000; @@ -450,8 +450,8 @@ export class TimePilot { this.context._timeWarpTransition = undefined; this.context._nextParachuteScore = scoring.parachute.min; this.context._gameArena = new GameArena(this.container); - this.context._renderTicker = new Ticker(); - this.context._gameTicker = new Ticker({ fps: gameFps }); + this.context._renderTicker = new Ticker({ fps: renderFps }); + this.context._gameTicker = new Ticker({ fixedStepFps: gameTickRate }); this.context._bullets = new BulletFactory(this.context); this.context._enemyBullets = new BulletFactory(this.context); this.context._player = new Player(this.context); @@ -1912,7 +1912,8 @@ export class TimePilot { const survivedSeconds = Math.max( 0, Math.floor( - (this.context._gameTicker.getTicks() - stats.startedAtTick) / gameFps + (this.context._gameTicker.getTicks() - stats.startedAtTick) / + gameTickRate ) ); From 38783e6227790d0e3bf47d2bca2398f06944e15c Mon Sep 17 00:00:00 2001 From: Rob Taylor <373278+manix84@users.noreply.github.com> Date: Sat, 30 May 2026 23:42:10 +0100 Subject: [PATCH 02/10] Add game speed and render FPS options with normalization and menu integration --- package-lock.json | 4 +- package.json | 2 +- src/game/__tests__/achievements.test.ts | 2 + src/game/__tests__/context-modules.test.ts | 2 + src/game/__tests__/time-pilot.test.ts | 2 + src/game/__tests__/user-options.test.ts | 42 +++++++ src/game/engine/Ticker.ts | 27 ++++- src/game/game-timing.ts | 25 +++- src/game/i18n/de.ts | 3 + src/game/i18n/en.ts | 3 + src/game/i18n/es.ts | 3 + src/game/i18n/fr.ts | 3 + src/game/i18n/it.ts | 3 + src/game/i18n/nl.ts | 3 + src/game/i18n/ro.ts | 3 + src/game/index.ts | 29 ++++- src/game/menus.ts | 130 ++++++++++++++++++++- src/game/menus/__tests__/menus.test.ts | 75 ++++++++++-- src/game/systems/__tests__/systems.test.ts | 2 + src/game/types.ts | 5 + src/game/user-options.ts | 16 +++ 21 files changed, 360 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index ee48537..4b38f6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "time-pilot", - "version": "25.0.0", + "version": "26.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "time-pilot", - "version": "25.0.0", + "version": "26.0.0", "dependencies": { "classnames": "^2.5.1", "pg": "^8.21.0", diff --git a/package.json b/package.json index b2d38c7..0c2f86d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "time-pilot", "private": true, - "version": "25.0.0", + "version": "26.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/game/__tests__/achievements.test.ts b/src/game/__tests__/achievements.test.ts index 3d4d95f..aa1df72 100644 --- a/src/game/__tests__/achievements.test.ts +++ b/src/game/__tests__/achievements.test.ts @@ -22,6 +22,8 @@ const createTicker = (getTicks: () => number): TickerInstance => ({ isRunning: true, start: vi.fn(), stop: vi.fn(), + setFixedStepFps: vi.fn(), + setFps: vi.fn(), addSchedule: vi.fn(() => 1), removeSchedule: vi.fn(() => true), clearSchedule: vi.fn(), diff --git a/src/game/__tests__/context-modules.test.ts b/src/game/__tests__/context-modules.test.ts index e98ffae..83a32b4 100644 --- a/src/game/__tests__/context-modules.test.ts +++ b/src/game/__tests__/context-modules.test.ts @@ -65,6 +65,8 @@ const createTicker = (): TickerInstance => { this.isRunning = false; callback?.(); }), + setFixedStepFps: vi.fn(), + setFps: vi.fn(), addSchedule: vi.fn(() => 1), removeSchedule: vi.fn(() => true), clearSchedule: vi.fn(), diff --git a/src/game/__tests__/time-pilot.test.ts b/src/game/__tests__/time-pilot.test.ts index 02d5095..eeb0ab5 100644 --- a/src/game/__tests__/time-pilot.test.ts +++ b/src/game/__tests__/time-pilot.test.ts @@ -50,11 +50,13 @@ describe("TimePilot engine", () => { userOptions.setDebugOption("invincible", true); localStorage.clear(); userOptions.setOption("controllerType", "keyboard1"); + userOptions.setOption("gameSpeed", 1); userOptions.setOption("gameZoom", 100); userOptions.setOption("gamepadEnabled", true); userOptions.setOption("keepScreenAwake", true); userOptions.setOption("language", "en"); userOptions.setOption("logLevel", "off"); + userOptions.setOption("renderFps", "max"); userOptions.setOption("uiZoom", 100); Object.defineProperty(HTMLMediaElement.prototype, "canPlay", { configurable: true, diff --git a/src/game/__tests__/user-options.test.ts b/src/game/__tests__/user-options.test.ts index 2c476c3..44dffc9 100644 --- a/src/game/__tests__/user-options.test.ts +++ b/src/game/__tests__/user-options.test.ts @@ -45,6 +45,44 @@ describe("user options persistence", () => { expect(userOptions.keepScreenAwake).toBe(true); expect(userOptions.language).toBe("en"); expect(userOptions.logLevel).toBe("off"); + expect(userOptions.gameSpeed).toBe(1); + expect(userOptions.renderFps).toBe("max"); + }); + + it("normalizes persisted render FPS and game speed options", async () => { + vi.resetModules(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: createStorageMock({ + "timePilot.userOptions": JSON.stringify({ + gameSpeed: 1.25, + renderFps: 30, + }), + }), + }); + + const { default: userOptions } = await import("../user-options"); + + expect(userOptions.gameSpeed).toBe(1.25); + expect(userOptions.renderFps).toBe(30); + }); + + it("ignores unsupported render FPS and game speed options", async () => { + vi.resetModules(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: createStorageMock({ + "timePilot.userOptions": JSON.stringify({ + gameSpeed: 9, + renderFps: 999, + }), + }), + }); + + const { default: userOptions } = await import("../user-options"); + + expect(userOptions.gameSpeed).toBe(1); + expect(userOptions.renderFps).toBe("max"); }); it("normalizes persisted log levels", async () => { @@ -144,10 +182,12 @@ describe("user options persistence", () => { const { default: userOptions, resetUserOptions } = await import("../user-options"); userOptions.setOption("uiZoom", 150); + userOptions.setOption("gameSpeed", 1.5); userOptions.setOption("language", "es"); userOptions.setOption("keepScreenAwake", false); userOptions.setOption("logLevel", "error"); userOptions.setOption("touchSteeringOverlay", false); + userOptions.setOption("renderFps", 30); userOptions.setKeyboardBinding("fire", [13]); resetUserOptions(); @@ -156,6 +196,8 @@ describe("user options persistence", () => { expect(userOptions.keepScreenAwake).toBe(true); expect(userOptions.language).toBe("en"); expect(userOptions.logLevel).toBe("off"); + expect(userOptions.gameSpeed).toBe(1); + expect(userOptions.renderFps).toBe("max"); expect(userOptions.touchSteeringOverlay).toBe(true); expect(userOptions.keyboardBindings.fire).toEqual([32]); expect(localStorage.getItem("timePilot.userOptions")).toBeNull(); diff --git a/src/game/engine/Ticker.ts b/src/game/engine/Ticker.ts index a51dc6f..8a531aa 100644 --- a/src/game/engine/Ticker.ts +++ b/src/game/engine/Ticker.ts @@ -34,8 +34,8 @@ const requestAnimationFrame = class Ticker implements TickerInstance { private _frame = 0; private _accumulatedStepTime = 0; - private readonly _fixedStepInterval?: number; - private readonly _frameInterval?: number; + private _fixedStepInterval?: number; + private _frameInterval?: number; private _lastStepTime: number | null = null; private readonly _maxCatchUpFrames: number; private _schedule: Record = {}; @@ -56,6 +56,24 @@ class Ticker implements TickerInstance { this._maxCatchUpFrames = options.maxCatchUpFrames ?? 10; } + setFps = (fps?: number): void => { + if (this._fixedStepInterval) { + throw new Error("Ticker cannot set fps while using fixedStepFps."); + } + + this._frameInterval = fps ? 1000 / fps : undefined; + this.resetTiming(); + }; + + setFixedStepFps = (fps: number): void => { + if (this._frameInterval) { + throw new Error("Ticker cannot set fixedStepFps while using fps."); + } + + this._fixedStepInterval = 1000 / fps; + this.resetTiming(); + }; + start = (): void => { if (this.isRunning) { return; @@ -105,6 +123,11 @@ class Ticker implements TickerInstance { } }; + private resetTiming = (): void => { + this._accumulatedStepTime = 0; + this._lastStepTime = null; + }; + private getFramesToRun = (timestamp: number): number => { if (this._fixedStepInterval) { return this.getFixedStepFrameCount(timestamp); diff --git a/src/game/game-timing.ts b/src/game/game-timing.ts index 6172f58..c84c7c7 100644 --- a/src/game/game-timing.ts +++ b/src/game/game-timing.ts @@ -3,8 +3,27 @@ */ export const gameTickRate = 50; +export const gameSpeedOptions = [0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 2] as const; +export const defaultGameSpeed = 1; + +export const renderFpsOptions = [30, 40, 50, 60, 75, 90, 120, 144, "max"] as const; +export type RenderFps = (typeof renderFpsOptions)[number]; +export const defaultRenderFps: RenderFps = "max"; + /** - * Render frame cap. This controls canvas paint cadence only, not simulation - * speed. + * Default render cadence. `"max"` lets the browser/display decide the upper + * bound through requestAnimationFrame. */ -export const renderFps = 60; +export const renderFps = defaultRenderFps; + +export const normalizeGameSpeed = (value: unknown): number => + typeof value === "number" && + gameSpeedOptions.includes(value as (typeof gameSpeedOptions)[number]) + ? value + : defaultGameSpeed; + +export const normalizeRenderFps = (value: unknown): RenderFps => + (typeof value === "number" || value === "max") && + renderFpsOptions.includes(value as RenderFps) + ? (value as RenderFps) + : defaultRenderFps; diff --git a/src/game/i18n/de.ts b/src/game/i18n/de.ts index 60d0d2d..3201f1f 100644 --- a/src/game/i18n/de.ts +++ b/src/game/i18n/de.ts @@ -72,6 +72,8 @@ const de: typeof en = { fire: "Feuer", filters: "Filters", fullScreen: "Vollbild", + fps: "FPS", + gameSpeed: "Game Speed", gameOver: "Spiel vorbei", gameZoom: "Spiel-Zoom", highScores: "Bestenliste", @@ -116,6 +118,7 @@ const de: typeof en = { error: "Fehler", fatal: "Kritisch", }, + max: "Max", masterVolume: "Gesamtlautstaerke", musicVolume: "Musiklautstaerke", off: "Aus", diff --git a/src/game/i18n/en.ts b/src/game/i18n/en.ts index 73fd402..a956d5f 100644 --- a/src/game/i18n/en.ts +++ b/src/game/i18n/en.ts @@ -70,6 +70,8 @@ const en = { fire: "Fire", filters: "Filters", fullScreen: "Full Screen", + fps: "FPS", + gameSpeed: "Game Speed", gameOver: "Game Over", gameZoom: "Game Zoom", highScores: "High Scores", @@ -114,6 +116,7 @@ const en = { error: "Error", fatal: "Fatal", }, + max: "Max", masterVolume: "Master Volume", musicVolume: "Music Volume", off: "Off", diff --git a/src/game/i18n/es.ts b/src/game/i18n/es.ts index 5b06413..84e4786 100644 --- a/src/game/i18n/es.ts +++ b/src/game/i18n/es.ts @@ -72,6 +72,8 @@ const es: typeof en = { fire: "Disparo", filters: "Filters", fullScreen: "Pantalla completa", + fps: "FPS", + gameSpeed: "Game Speed", gameOver: "Fin de la partida", gameZoom: "Zoom del juego", highScores: "Mejores puntuaciones", @@ -116,6 +118,7 @@ const es: typeof en = { error: "Error", fatal: "Critico", }, + max: "Max", masterVolume: "Volumen principal", musicVolume: "Volumen musica", off: "No", diff --git a/src/game/i18n/fr.ts b/src/game/i18n/fr.ts index 1f7b800..a63030c 100644 --- a/src/game/i18n/fr.ts +++ b/src/game/i18n/fr.ts @@ -72,6 +72,8 @@ const fr: typeof en = { fire: "Tir", filters: "Filters", fullScreen: "Plein ecran", + fps: "FPS", + gameSpeed: "Game Speed", gameOver: "Partie terminee", gameZoom: "Zoom jeu", highScores: "Meilleurs scores", @@ -104,6 +106,7 @@ const fr: typeof en = { error: "Erreur", fatal: "Critique", }, + max: "Max", masterVolume: "Volume principal", musicVolume: "Volume musique", off: "Non", diff --git a/src/game/i18n/it.ts b/src/game/i18n/it.ts index 7137c3f..d7ae433 100644 --- a/src/game/i18n/it.ts +++ b/src/game/i18n/it.ts @@ -72,6 +72,8 @@ const it: typeof en = { fire: "Fuoco", filters: "Filters", fullScreen: "Schermo intero", + fps: "FPS", + gameSpeed: "Game Speed", gameOver: "Fine partita", gameZoom: "Zoom gioco", highScores: "Punteggi migliori", @@ -116,6 +118,7 @@ const it: typeof en = { error: "Errore", fatal: "Critico", }, + max: "Max", masterVolume: "Volume principale", musicVolume: "Volume musica", off: "No", diff --git a/src/game/i18n/nl.ts b/src/game/i18n/nl.ts index 6c44764..021e8e6 100644 --- a/src/game/i18n/nl.ts +++ b/src/game/i18n/nl.ts @@ -72,6 +72,8 @@ const nl: typeof en = { fire: "Vuur", filters: "Filters", fullScreen: "Volledig scherm", + fps: "FPS", + gameSpeed: "Game Speed", gameOver: "Spel voorbij", gameZoom: "Gamezoom", highScores: "Topscores", @@ -116,6 +118,7 @@ const nl: typeof en = { error: "Fout", fatal: "Kritiek", }, + max: "Max", masterVolume: "Hoofdvolume", musicVolume: "Muziekvolume", off: "Uit", diff --git a/src/game/i18n/ro.ts b/src/game/i18n/ro.ts index 665db0c..1438930 100644 --- a/src/game/i18n/ro.ts +++ b/src/game/i18n/ro.ts @@ -72,6 +72,8 @@ const ro: typeof en = { fire: "Foc", filters: "Filters", fullScreen: "Ecran complet", + fps: "FPS", + gameSpeed: "Game Speed", gameOver: "Joc terminat", gameZoom: "Zoom joc", highScores: "Scoruri maxime", @@ -116,6 +118,7 @@ const ro: typeof en = { error: "Eroare", fatal: "Critic", }, + max: "Max", masterVolume: "Volum principal", musicVolume: "Volum muzica", off: "Oprit", diff --git a/src/game/index.ts b/src/game/index.ts index 000d0b4..7472ee3 100644 --- a/src/game/index.ts +++ b/src/game/index.ts @@ -13,7 +13,7 @@ import EnemyFactory from "./enemy-factory"; import GameArena from "./engine/arena"; import SoundEngine from "./engine/Sound"; import Ticker from "./engine/Ticker"; -import { gameTickRate, renderFps } from "./game-timing"; +import { gameTickRate } from "./game-timing"; import { getHighScores, getHighScoreThresholds, @@ -359,6 +359,10 @@ export class TimePilot { this.saveGameSessionSnapshot(); this.removeSessionSnapshotListeners(); this.removeBrowserHistoryNavigationListener(); + window.removeEventListener( + "timePilot:userOptionsChanged", + this.syncTickerTiming + ); this.releaseScreenWakeLock(); this.isDestroyed = true; this.isDemoMode = false; @@ -389,6 +393,17 @@ export class TimePilot { this.context._gameArena.destroy?.(); }; + private getRenderFpsCap = (): number | undefined => + userOptions.renderFps === "max" ? undefined : userOptions.renderFps; + + private getEffectiveGameTickRate = (): number => + gameTickRate * userOptions.gameSpeed; + + private syncTickerTiming = (): void => { + this.context._renderTicker.setFps(this.getRenderFpsCap()); + this.context._gameTicker.setFixedStepFps(this.getEffectiveGameTickRate()); + }; + pauseGame = (forcePause?: boolean): void => { if (this.context._gameTicker.isRunning || !!forcePause) { logger.info("Pausing game"); @@ -450,8 +465,12 @@ export class TimePilot { this.context._timeWarpTransition = undefined; this.context._nextParachuteScore = scoring.parachute.min; this.context._gameArena = new GameArena(this.container); - this.context._renderTicker = new Ticker({ fps: renderFps }); - this.context._gameTicker = new Ticker({ fixedStepFps: gameTickRate }); + this.context._renderTicker = new Ticker({ + fps: this.getRenderFpsCap(), + }); + this.context._gameTicker = new Ticker({ + fixedStepFps: this.getEffectiveGameTickRate(), + }); this.context._bullets = new BulletFactory(this.context); this.context._enemyBullets = new BulletFactory(this.context); this.context._player = new Player(this.context); @@ -581,6 +600,10 @@ export class TimePilot { this.addSessionSnapshotListeners(); this.addBrowserHistoryNavigationListener(); + window.addEventListener( + "timePilot:userOptionsChanged", + this.syncTickerTiming + ); this.context._player.setData("level", 1); this.context._gameArena.renderText("Loading", 20, 10, { size: 30 }); diff --git a/src/game/menus.ts b/src/game/menus.ts index f29a41d..2850e6d 100644 --- a/src/game/menus.ts +++ b/src/game/menus.ts @@ -11,6 +11,7 @@ import { filterPresets, normalizeFilterIntensity, } from "./filter-settings"; +import { gameSpeedOptions, renderFpsOptions } from "./game-timing"; import { fakeHighScores } from "./high-scores"; import i18n, { availableLanguages, @@ -192,6 +193,8 @@ interface LevelShowcaseProjectile { } const controllerTypes: ControllerType[] = ["keyboard1", "keyboard2"]; +const gameSpeedStepCount = gameSpeedOptions.length - 1; +const renderFpsStepCount = renderFpsOptions.length - 1; const menuEdgePadding = 24; const menuDesignHeight = 500; const menuDesignWidth = 660; @@ -289,6 +292,11 @@ class Menus implements MenuSystemInstance { private _shouldRevealSelected = true; private _showRestartFromStart = false; private _sliderDragIndex: number | null = null; + private _browserMaxFpsEstimate = 60; + private _browserMaxFpsEstimateLocked = false; + private _browserMaxFpsSampleCount = 0; + private _browserMaxFpsSampleTotal = 0; + private _lastBrowserMaxFpsSampleAt: number | null = null; private _startLabel = i18n.menu.start; private _scrollY = 0; private _transition: MenuTransition | null = null; @@ -759,6 +767,8 @@ class Menus implements MenuSystemInstance { return; } + this._sampleBrowserMaxFps(); + const renderLogo = options.renderLogo ?? true; const context = this._gameArena.getContext() as CanvasRenderingContext2D; const menuScale = this._getMenuScale(); @@ -1274,6 +1284,18 @@ class Menus implements MenuSystemInstance { this._gameArena.isFullScreen() ? i18n.menu.on : i18n.menu.off, onAdjust: () => this._gameArena.toggleFullScreen(), }), + this._createItem(i18n.menu.fps, "slider", nextItemY(), { + getValue: () => this._formatRenderFps(), + onAdjust: (direction) => this._adjustRenderFps(direction), + onSetValue: (value) => this._setRenderFpsFromStep(value), + sliderSteps: renderFpsStepCount, + }), + this._createItem(i18n.menu.gameSpeed, "slider", nextItemY(), { + getValue: () => this._formatGameSpeed(), + onAdjust: (direction) => this._adjustGameSpeed(direction), + onSetValue: (value) => this._setGameSpeedFromStep(value), + sliderSteps: gameSpeedStepCount, + }), ]; if (showWakeLock) { @@ -3259,7 +3281,11 @@ class Menus implements MenuSystemInstance { ? userOptions.uiZoom : item.label === i18n.menu.gameZoom ? userOptions.gameZoom - : Number(item.getValue()); + : item.label === i18n.menu.fps + ? this._getRenderFpsStep() + : item.label === i18n.menu.gameSpeed + ? this._getGameSpeedStep() + : Number(item.getValue()); if (!Number.isFinite(value)) { return null; } @@ -3271,6 +3297,12 @@ class Menus implements MenuSystemInstance { ); } + if (item.label === i18n.menu.fps || item.label === i18n.menu.gameSpeed) { + const sliderMax = item.sliderSteps ?? 0; + + return sliderMax > 0 ? Math.max(0, Math.min(1, value / sliderMax)) : 0; + } + const sliderMin = item.sliderMin ?? 0; const sliderMax = item.sliderSteps ?? 10; @@ -4209,6 +4241,102 @@ class Menus implements MenuSystemInstance { ); }; + private _adjustRenderFps = (direction: -1 | 1): void => { + this._setRenderFpsFromStep(this._getRenderFpsStep() + direction); + }; + + private _setRenderFpsFromStep = (step: number): void => { + const nextStep = Math.max(0, Math.min(renderFpsStepCount, Math.round(step))); + + userOptions.setOption("renderFps", renderFpsOptions[nextStep]); + }; + + private _getRenderFpsStep = (): number => { + const index = renderFpsOptions.indexOf( + userOptions.renderFps as (typeof renderFpsOptions)[number] + ); + + return index === -1 ? renderFpsOptions.indexOf("max") : index; + }; + + private _adjustGameSpeed = (direction: -1 | 1): void => { + this._setGameSpeedFromStep(this._getGameSpeedStep() + direction); + }; + + private _setGameSpeedFromStep = (step: number): void => { + const nextStep = Math.max(0, Math.min(gameSpeedStepCount, Math.round(step))); + + userOptions.setOption("gameSpeed", gameSpeedOptions[nextStep]); + }; + + private _getGameSpeedStep = (): number => { + const index = gameSpeedOptions.indexOf( + userOptions.gameSpeed as (typeof gameSpeedOptions)[number] + ); + + return index === -1 ? gameSpeedOptions.indexOf(1) : index; + }; + + private _formatGameSpeed = (): string => `${userOptions.gameSpeed.toFixed(2)}x`; + + private _formatRenderFps = (): string => + userOptions.renderFps === "max" + ? `${i18n.menu.max} (${this._browserMaxFpsEstimate})` + : this._formatRenderFpsCap(userOptions.renderFps); + + private _formatRenderFpsCap = (fps: number): string => { + if (fps === 50) { + return "50 (PAL)"; + } + + if (fps === 60) { + return "60 (NTSC)"; + } + + return `${fps}`; + }; + + private _sampleBrowserMaxFps = (): void => { + if (userOptions.renderFps !== "max") { + this._lastBrowserMaxFpsSampleAt = null; + return; + } + + const now = performance.now(); + + if (this._lastBrowserMaxFpsSampleAt === null) { + this._lastBrowserMaxFpsSampleAt = now; + return; + } + + const elapsedMs = now - this._lastBrowserMaxFpsSampleAt; + this._lastBrowserMaxFpsSampleAt = now; + + if (elapsedMs <= 0 || elapsedMs > 250) { + return; + } + + const measuredFps = 1000 / elapsedMs; + this._browserMaxFpsSampleCount += 1; + this._browserMaxFpsSampleTotal += measuredFps; + + if (this._browserMaxFpsEstimateLocked || this._browserMaxFpsSampleCount < 20) { + return; + } + + const averageFps = + this._browserMaxFpsSampleTotal / this._browserMaxFpsSampleCount; + const commonRefreshRates = [30, 50, 60, 75, 90, 100, 120, 144, 165, 240]; + const nearestRefreshRate = commonRefreshRates.reduce((nearest, candidate) => + Math.abs(candidate - averageFps) < Math.abs(nearest - averageFps) + ? candidate + : nearest + ); + + this._browserMaxFpsEstimate = nearestRefreshRate; + this._browserMaxFpsEstimateLocked = true; + }; + private _toggleKeepScreenAwake = (): void => { userOptions.setOption("keepScreenAwake", !userOptions.keepScreenAwake); this._commands.syncScreenWakeLock?.(); diff --git a/src/game/menus/__tests__/menus.test.ts b/src/game/menus/__tests__/menus.test.ts index 7017597..2da28f3 100644 --- a/src/game/menus/__tests__/menus.test.ts +++ b/src/game/menus/__tests__/menus.test.ts @@ -52,12 +52,14 @@ describe("menu definitions", () => { userOptions.setOption("controllerType", "keyboard1"); userOptions.setOption("debugContinues", 3); userOptions.setOption("debugLives", 3); + userOptions.setOption("gameSpeed", 1); userOptions.setOption("language", "en"); userOptions.setOption("logLevel", "off"); userOptions.setOption("keepScreenAwake", true); userOptions.setOption("touchSteeringOverlay", true); userOptions.setOption("gameZoom", 100); userOptions.setOption("masterVolume", 10); + userOptions.setOption("renderFps", "max"); userOptions.setOption("uiZoom", 100); userOptions.setOption("filterSettings", { ...filterPresets.off }); userOptions.setOption("videoFilterMode", "off"); @@ -236,7 +238,7 @@ describe("menu definitions", () => { expect(fillRectCalls).toEqual( expect.arrayContaining([ - [-137, 391, 3, 3], + [-137, 475, 3, 3], ]) ); }); @@ -958,7 +960,7 @@ describe("menu definitions", () => { menus.next(); menus.activate(); - for (let i = 0; i < 7; i++) { + for (let i = 0; i < 9; i++) { menus.next(); } @@ -1006,7 +1008,7 @@ describe("menu definitions", () => { pwaMenus.showStart(); pwaMenus.next(); pwaMenus.activate(); - for (let i = 0; i < 7; i++) { + for (let i = 0; i < 9; i++) { pwaMenus.next(); } pwaMenus.render(); @@ -1041,7 +1043,7 @@ describe("menu definitions", () => { menus.showStart(); menus.next(); menus.activate(); - for (let i = 0; i < 7; i++) { + for (let i = 0; i < 9; i++) { menus.next(); } menus.render(); @@ -1175,16 +1177,61 @@ describe("menu definitions", () => { ); }); - it("uses escape and backspace to return from submenus", () => { + it("adjusts render FPS and game speed from the options menu", () => { const arena = createArena(); const menus = new Menus(arena, { start: vi.fn() }); + userOptions.setOption("renderFps", "max"); + userOptions.setOption("gameSpeed", 1); menus.showStart(); menus.next(); menus.activate(); - expect(menus.captureKey(8)).toBe(true); + for (let i = 0; i < 7; i++) { + menus.next(); + } + + menus.render(); + expect(arena.renderText).toHaveBeenCalledWith( + "Max (60)", + expect.any(Number), + expect.any(Number), + expect.objectContaining({ align: "right" }) + ); + + menus.adjust(-1); + expect(userOptions.renderFps).toBe(144); + + menus.next(); + menus.adjust(-1); + expect(userOptions.gameSpeed).toBe(0.9); + menus.render(); + + expect(arena.renderText).toHaveBeenCalledWith( + "144", + expect.any(Number), + expect.any(Number), + expect.objectContaining({ align: "right" }) + ); + expect(arena.renderText).toHaveBeenCalledWith( + "0.90x", + expect.any(Number), + expect.any(Number), + expect.objectContaining({ align: "right" }) + ); + }); + + it("uses escape and backspace to return from submenus", () => { + const arena = createArena(); + const backspaceMenus = new Menus(arena, { start: vi.fn() }); + + backspaceMenus.showStart(); + backspaceMenus.next(); + backspaceMenus.activate(); + + expect(backspaceMenus.captureKey(8)).toBe(true); + backspaceMenus.render(); expect(arena.renderText).toHaveBeenCalledWith( "Start", expect.any(Number), @@ -1192,11 +1239,15 @@ describe("menu definitions", () => { expect.objectContaining({ align: "left" }) ); + const menus = new Menus(arena, { start: vi.fn() }); + menus.showStart(); menus.next(); menus.activate(); - for (let i = 0; i < 9; i++) { + + for (let i = 0; i < 11; i++) { menus.next(); } + menus.activate(); expect(menus.captureKey(27)).toBe(true); @@ -1531,7 +1582,7 @@ describe("menu definitions", () => { expect.objectContaining({ align: "center" }) ); - for (let i = 0; i < 10; i++) { + for (let i = 0; i < 12; i++) { menus.next(); } @@ -1909,7 +1960,7 @@ describe("menu definitions", () => { menus.next(); menus.next(); menus.activate(); - for (let i = 0; i < 8; i++) { + for (let i = 0; i < 10; i++) { menus.next(); } menus.activate(); @@ -1955,7 +2006,7 @@ describe("menu definitions", () => { menus.next(); menus.activate(); - for (let i = 0; i < 9; i++) { + for (let i = 0; i < 11; i++) { menus.next(); } @@ -1975,7 +2026,7 @@ describe("menu definitions", () => { menus.next(); menus.activate(); - for (let i = 0; i < 8; i++) { + for (let i = 0; i < 10; i++) { menus.next(); } @@ -2007,7 +2058,7 @@ describe("menu definitions", () => { menus.next(); menus.activate(); - for (let i = 0; i < 9; i++) { + for (let i = 0; i < 11; i++) { menus.next(); } diff --git a/src/game/systems/__tests__/systems.test.ts b/src/game/systems/__tests__/systems.test.ts index 239d8a4..fa5eacb 100644 --- a/src/game/systems/__tests__/systems.test.ts +++ b/src/game/systems/__tests__/systems.test.ts @@ -54,6 +54,8 @@ const createTicker = (): TickerInstance => ({ isRunning: true, start: vi.fn(), stop: vi.fn(), + setFixedStepFps: vi.fn(), + setFps: vi.fn(), addSchedule: vi.fn(() => 1), removeSchedule: vi.fn(() => true), clearSchedule: vi.fn(), diff --git a/src/game/types.ts b/src/game/types.ts index dab82f6..f516496 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -154,6 +154,7 @@ import type { } from "./filter-settings"; import type AchievementSystem from "./achievements"; import type { AchievementStatus } from "./achievements"; +import type { RenderFps } from "./game-timing"; import type { LogLevel } from "./log-levels"; import type { StoredDataResetScope } from "./storage-reset"; export type GameLanguage = "de" | "en" | "es" | "fr" | "it" | "nl" | "ro"; @@ -239,6 +240,8 @@ 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; @@ -862,6 +865,7 @@ export interface UserOptions { controllerType: ControllerType; debugContinues: number; debugLives: number; + gameSpeed: number; gameZoom: number; gamepadEnabled: boolean; /** @@ -879,6 +883,7 @@ export interface UserOptions { masterVolume: number; musicVolume: number; effectsVolume: number; + renderFps: RenderFps; uiZoom: number; setKeyboardBinding: ( key: K, diff --git a/src/game/user-options.ts b/src/game/user-options.ts index 7b13a79..75eb5ca 100644 --- a/src/game/user-options.ts +++ b/src/game/user-options.ts @@ -13,6 +13,12 @@ import { normalizeFilterIntensity, normalizeFilterSettings, } from "./filter-settings"; +import { + defaultGameSpeed, + defaultRenderFps, + normalizeGameSpeed, + normalizeRenderFps, +} from "./game-timing"; import { isLogLevel } from "./log-levels"; const supportedLanguages: GameLanguage[] = [ @@ -40,6 +46,7 @@ type PersistedUserOptions = Pick< | "enableDebug" | "effectsVolume" | "gamepadEnabled" + | "gameSpeed" | "gameZoom" | "filterSettings" | "keyboardBindings" @@ -48,6 +55,7 @@ type PersistedUserOptions = Pick< | "logLevel" | "masterVolume" | "musicVolume" + | "renderFps" | "touchSteeringOverlay" | "uiZoom" | "videoFilterMode" @@ -94,6 +102,7 @@ const defaultPersistedOptions: PersistedUserOptions = { controllerType: "keyboard1" as ControllerType, gameZoom: zoomDefaultPercent, gamepadEnabled: true, + gameSpeed: defaultGameSpeed, filterSettings: defaultCustomFilterSettings, keyboardBindings: defaultKeyboardBindings, keepScreenAwake: true, @@ -101,6 +110,7 @@ const defaultPersistedOptions: PersistedUserOptions = { logLevel: "off", masterVolume: 8, musicVolume: 2, + renderFps: defaultRenderFps, effectsVolume: 8, touchSteeringOverlay: true, uiZoom: zoomDefaultPercent, @@ -254,6 +264,7 @@ const writeUserOptions = (): void => { enableDebug: userOptions.enableDebug, effectsVolume: userOptions.effectsVolume, gamepadEnabled: userOptions.gamepadEnabled, + gameSpeed: userOptions.gameSpeed, gameZoom: userOptions.gameZoom, filterSettings: userOptions.filterSettings, keyboardBindings: userOptions.keyboardBindings, @@ -262,6 +273,7 @@ const writeUserOptions = (): void => { logLevel: userOptions.logLevel, masterVolume: userOptions.masterVolume, musicVolume: userOptions.musicVolume, + renderFps: userOptions.renderFps, optionsVersion: userOptionsVersion, touchSteeringOverlay: userOptions.touchSteeringOverlay, uiZoom: userOptions.uiZoom, @@ -374,6 +386,7 @@ var userOptions: UserOptions = { */ gamepadEnabled: storedOptions.gamepadEnabled ?? defaultPersistedOptions.gamepadEnabled, + gameSpeed: normalizeGameSpeed(storedOptions.gameSpeed), filterSettings: normalizeFilterSettings(storedOptions.filterSettings), @@ -389,6 +402,7 @@ var userOptions: UserOptions = { logLevel: storedLogLevel, masterVolume: storedOptions.masterVolume ?? defaultPersistedOptions.masterVolume, musicVolume: storedOptions.musicVolume ?? defaultPersistedOptions.musicVolume, + renderFps: normalizeRenderFps(storedOptions.renderFps), effectsVolume: storedOptions.effectsVolume ?? defaultPersistedOptions.effectsVolume, /** * Display a live touch steering guide during gameplay. @@ -438,6 +452,7 @@ export const resetUserOptions = (): void => { userOptions.controllerType = defaults.controllerType; userOptions.gameZoom = defaults.gameZoom; userOptions.gamepadEnabled = defaults.gamepadEnabled; + userOptions.gameSpeed = defaults.gameSpeed; userOptions.filterSettings = defaults.filterSettings; userOptions.keyboardBindings = defaults.keyboardBindings; userOptions.keepScreenAwake = defaults.keepScreenAwake; @@ -445,6 +460,7 @@ export const resetUserOptions = (): void => { userOptions.logLevel = defaults.logLevel; userOptions.masterVolume = defaults.masterVolume; userOptions.musicVolume = defaults.musicVolume; + userOptions.renderFps = defaults.renderFps; userOptions.effectsVolume = defaults.effectsVolume; userOptions.touchSteeringOverlay = defaults.touchSteeringOverlay; userOptions.uiZoom = defaults.uiZoom; From 34d37a89a2dca1f269593e8c038ab4d677fc340c Mon Sep 17 00:00:00 2001 From: Rob Taylor <373278+manix84@users.noreply.github.com> Date: Sat, 30 May 2026 23:46:45 +0100 Subject: [PATCH 03/10] Add settings support to high score entries and update related logic --- package-lock.json | 4 +- package.json | 2 +- server/high-score-server.mjs | 46 +++++++++++++-- src/game/__tests__/high-scores.test.ts | 72 +++++++++++++++++++++-- src/game/__tests__/time-pilot.test.ts | 10 +++- src/game/high-scores.ts | 79 +++++++++++++++++++------- src/game/index.ts | 19 ++++++- src/game/types.ts | 7 +++ 8 files changed, 202 insertions(+), 37 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4b38f6f..0b6264e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "time-pilot", - "version": "26.0.0", + "version": "27.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "time-pilot", - "version": "26.0.0", + "version": "27.0.0", "dependencies": { "classnames": "^2.5.1", "pg": "^8.21.0", diff --git a/package.json b/package.json index 0c2f86d..51f38ee 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "time-pilot", "private": true, - "version": "26.0.0", + "version": "27.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server/high-score-server.mjs b/server/high-score-server.mjs index c9825e4..17b409e 100644 --- a/server/high-score-server.mjs +++ b/server/high-score-server.mjs @@ -29,7 +29,7 @@ const maxPlausibleBonuses = 200; const maxPlausibleBosses = 5; const maxPlausibleEnemies = 2000; const maxPlausibleShots = 10000; -const maxScoreStats = 12; +const maxScoreStats = 16; const maxScores = 100; const maxPublicScores = 25; const maxPortAttempts = 20; @@ -195,6 +195,7 @@ const ensurePostgresSchema = async (pool) => { created_at bigint, name text not null, score integer not null, + settings jsonb, stats jsonb not null, game_version text not null, submitted_at bigint not null, @@ -205,6 +206,9 @@ const ensurePostgresSchema = async (pool) => { await pool.query( "alter table time_pilot_high_scores add column if not exists created_at bigint" ); + await pool.query( + "alter table time_pilot_high_scores add column if not exists settings jsonb" + ); }; const createPostgresStore = (pool) => ({ @@ -240,14 +244,15 @@ const createPostgresStore = (pool) => ({ await client.query( `insert into time_pilot_high_scores - (id, created_at, name, score, stats, game_version, submitted_at, received_at, run_id) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9) + (id, created_at, name, score, settings, stats, game_version, submitted_at, received_at, run_id) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) on conflict (id) do nothing`, [ score.id, score.createdAt, score.name, score.score, + JSON.stringify(score.settings ?? null), JSON.stringify(score.stats), score.gameVersion, score.submittedAt, @@ -273,7 +278,7 @@ const createPostgresStore = (pool) => ({ }, async listScores(limit = maxPublicScores) { const result = await pool.query( - `select id, created_at, name, score, stats, received_at + `select id, created_at, name, score, settings, stats, received_at from time_pilot_high_scores order by score desc, coalesce(created_at, received_at) asc limit $1`, @@ -286,6 +291,7 @@ const createPostgresStore = (pool) => ({ name: row.name, receivedAt: Number(row.received_at), score: Number(row.score), + settings: normalizeScoreSettings(row.settings), stats: row.stats, })); }, @@ -583,6 +589,7 @@ const validateScoreSubmission = async (payload) => { id: randomUUID(), name: normalizeName(entry.name), score: Math.max(0, Math.floor(entry.score)), + settings: normalizeScoreSettings(entry.settings), stats: entry.stats.slice(0, maxScoreStats), }, submittedAt: Math.max(0, Math.floor(submittedAt)), @@ -707,6 +714,7 @@ const createHighScoreIntegrityChecksum = ( entry.id, entry.name, Math.max(0, Math.floor(entry.score)), + formatScoreSettingsForIntegrity(entry.settings), entry.stats.join("\n"), submittedAt, multiplier, @@ -798,8 +806,9 @@ const isPublicScore = (value) => (value.createdAt === undefined || typeof value.createdAt === "number") && typeof value.name === "string" && typeof value.score === "number" && + (value.settings === undefined || normalizeScoreSettings(value.settings)) && Array.isArray(value.stats) && - value.stats.length <= 12 && + value.stats.length <= maxScoreStats && value.stats.every((stat) => typeof stat === "string" && stat.length <= 80); const toPublicScore = (score) => ({ @@ -808,9 +817,36 @@ const toPublicScore = (score) => ({ name: normalizeName(score.name), receivedAt: score.receivedAt, score: Math.max(0, Math.floor(score.score)), + ...(score.settings ? { settings: normalizeScoreSettings(score.settings) } : {}), stats: score.stats.slice(0, maxScoreStats), }); +const normalizeScoreSettings = (value) => { + if (!value || typeof value !== "object") { + return undefined; + } + + const gameSpeed = + typeof value.gameSpeed === "number" && Number.isFinite(value.gameSpeed) + ? value.gameSpeed + : 1; + const renderFps = + value.renderFps === "max" || + (typeof value.renderFps === "number" && Number.isFinite(value.renderFps)) + ? value.renderFps + : "max"; + + return { gameSpeed, renderFps }; +}; + +const formatScoreSettingsForIntegrity = (settings) => + settings + ? JSON.stringify({ + gameSpeed: settings.gameSpeed, + renderFps: settings.renderFps, + }) + : ""; + const normalizeName = (name) => { const normalized = name .trim() diff --git a/src/game/__tests__/high-scores.test.ts b/src/game/__tests__/high-scores.test.ts index 895bcab..9e3398e 100644 --- a/src/game/__tests__/high-scores.test.ts +++ b/src/game/__tests__/high-scores.test.ts @@ -153,19 +153,83 @@ describe("high score storage", () => { }); it("keeps saved scores visible ahead of placeholder scores", () => { - saveHighScore("New Pilot", 1200, ["Era: 1910"]); + saveHighScore("New Pilot", 1200, ["Era: 1910"], null, { + gameSpeed: 1.25, + renderFps: 50, + }); expect(getHighScores()[0]).toMatchObject({ name: "New Pilot", score: 1200, + settings: { + gameSpeed: 1.25, + renderFps: 50, + }, }); expect(getHighScoreThresholds(3).map((score) => score.score)).toEqual([ - 1000000, - 875500, - 742250, + 120000, + 90000, + 70000, ]); }); + it("syncs receipt-backed score timing settings", async () => { + const remoteEntry = { + id: "remote-settings-score", + createdAt: 2000, + name: "Settings Pilot", + receivedAt: 3000, + score: 5000, + settings: { + gameSpeed: 0.9, + renderFps: "max", + }, + stats: ["Era: 1910"], + }; + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation( + (input, init) => { + if (input === "/api/high-scores" && init?.method === "POST") { + return Promise.resolve(createJsonResponse(remoteEntry)); + } + + return Promise.resolve(createJsonResponse([remoteEntry])); + } + ); + + saveHighScore( + "Settings Pilot", + 5000, + ["Era: 1910"], + { + issuedAt: 1000, + runId: "run-settings", + token: "receipt-token", + }, + { + gameSpeed: 0.9, + renderFps: "max", + } + ); + + await syncHighScores(); + + const postCall = fetchMock.mock.calls.find( + ([input, init]) => input === "/api/high-scores" && init?.method === "POST" + ); + const body = JSON.parse(String(postCall?.[1]?.body ?? "{}")) as { + entry?: { settings?: unknown }; + }; + + expect(body.entry?.settings).toEqual({ + gameSpeed: 0.9, + renderFps: "max", + }); + expect(loadStoredHighScores()[0]?.settings).toEqual({ + gameSpeed: 0.9, + renderFps: "max", + }); + }); + it("keeps gameplay-safe score saves best effort when storage writes fail", () => { vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); diff --git a/src/game/__tests__/time-pilot.test.ts b/src/game/__tests__/time-pilot.test.ts index eeb0ab5..371c465 100644 --- a/src/game/__tests__/time-pilot.test.ts +++ b/src/game/__tests__/time-pilot.test.ts @@ -730,13 +730,13 @@ describe("TimePilot engine", () => { pilot.beginGame(); playedSources.length = 0; - pilot.context._player.setData("score", 742251); + pilot.context._player.setData("score", 70001); expect(pilot.context._scoreTrophyRank).toBe(3); expect(pilot.context._hasReachedHighScore).toBe(false); expect(playedSources).not.toContain(sounds.highScore.src); - pilot.context._player.setData("score", 875501); + pilot.context._player.setData("score", 90001); expect(pilot.context._scoreTrophyRank).toBe(2); expect(pilot.context._hasReachedHighScore).toBe(false); @@ -780,7 +780,11 @@ describe("TimePilot engine", () => { runId: string; token: string; } | null; - pendingHighScore: { score: number; stats: string[] } | null; + pendingHighScore: { + score: number; + settings?: { gameSpeed: number; renderFps: "max" | number }; + stats: string[]; + } | null; savePendingHighScore: (name: string) => void; }; diff --git a/src/game/high-scores.ts b/src/game/high-scores.ts index 3cf4de1..ac93788 100644 --- a/src/game/high-scores.ts +++ b/src/game/high-scores.ts @@ -1,4 +1,9 @@ -import type { HighScoreEntry, HighScoreSyncStatus } from "./types"; +import { normalizeGameSpeed, normalizeRenderFps } from "./game-timing"; +import type { + HighScoreEntry, + HighScoreSettings, + HighScoreSyncStatus, +} from "./types"; type HighScoreSyncState = "local" | "pending" | "synced"; @@ -34,7 +39,7 @@ const highScoreIntegrityVersion = 1; const highScoreIntegrityHashModulo = 1000003; const highScoreIntegrityMinMultiplier = 101; const highScoreIntegrityMultiplierRange = 897; -const maxHighScoreStats = 12; +const maxHighScoreStats = 16; const maxStoredHighScores = 10; const maxCachedHighScores = 50; let highScoreApiOffline = false; @@ -102,64 +107,64 @@ export const fakeHighScores: HighScoreEntry[] = [ createdAt: fakeHighScoreBaseCreatedAt, id: "shooty-mcshootface", name: "Shooty McShootface", - score: 1000000, - stats: ["Era: 2001", "Bosses: 5", "Continues: 0", "Accuracy: suspicious"], + score: 120000, + stats: ["Era: 1970", "Bosses: 2", "Continues: 0", "Accuracy: suspicious"], }, { createdAt: fakeHighScoreBaseCreatedAt + 86400000, id: "captain-definitely-real", name: "Captain Definitely Real", - score: 875500, - stats: ["Era: 1982", "Bosses: 4", "Lives left: 1", "Clouds dodged: all"], + score: 90000, + stats: ["Era: 1940", "Bosses: 1", "Lives left: 1", "Clouds dodged: many"], }, { createdAt: fakeHighScoreBaseCreatedAt + 86400000 * 2, id: "pewpew-von-laser", name: "PewPew von Laser", - score: 742250, - stats: ["Era: 1970", "Missiles annoyed: 312", "Continues: 1"], + score: 70000, + stats: ["Era: 1940", "Missiles annoyed: 73", "Continues: 0"], }, { createdAt: fakeHighScoreBaseCreatedAt + 86400000 * 3, id: "baron-von-biplane", name: "Baron von Biplane", - score: 501910, - stats: ["Era: 1940", "Loops: too many", "Near misses: 88"], + score: 55000, + stats: ["Era: 1910", "Loops: too many", "Near misses: 38"], }, { createdAt: fakeHighScoreBaseCreatedAt + 86400000 * 4, id: "not-a-bot-9000", name: "Not A Bot 9000", - score: 404404, + score: 42000, stats: ["Era: 1910", "Inputs: perfectly normal", "Snacks: 3"], }, { createdAt: fakeHighScoreBaseCreatedAt + 86400000 * 5, id: "debug-dave", name: "Debug Dave", - score: 123456, + score: 30000, stats: ["Era: 1910", "Hitboxes blamed: yes", "Restart count: private"], }, { createdAt: fakeHighScoreBaseCreatedAt + 86400000 * 6, id: "loop-de-loop-lou", name: "Loop-de-Loop Lou", - score: 98765, - stats: ["Era: 1910", "Loops: 42", "Near misses: 0", "Dignity: optional"], + score: 22000, + stats: ["Era: 1910", "Loops: 12", "Near misses: 0", "Dignity: optional"], }, { createdAt: fakeHighScoreBaseCreatedAt + 86400000 * 7, id: "captain-one-more", name: "Captain One More", - score: 76543, + score: 15000, stats: ["Era: 1910", "Restarts: 9", "Continues: 3", "Sleep: none"], }, { createdAt: fakeHighScoreBaseCreatedAt + 86400000 * 8, id: "miss-by-a-mile", name: "Missed By A Pixel", - score: 54321, - stats: ["Era: 1910", "Near misses: 128", "Luck: suspicious"], + score: 9000, + stats: ["Era: 1910", "Near misses: 28", "Luck: suspicious"], }, { createdAt: fakeHighScoreBaseCreatedAt + 86400000 * 9, @@ -200,7 +205,9 @@ export const getHighScoreThresholds = (limit: number): HighScoreEntry[] => /** * Starts a remotely verifiable high-score run when the API is available. */ -export const startHighScoreRun = async (): Promise => { +export const startHighScoreRun = async ( + settings?: HighScoreSettings +): Promise => { if (!canUseHighScoreApi()) { setHighScoreSyncStatus("error"); return null; @@ -216,6 +223,7 @@ export const startHighScoreRun = async (): Promise = }, body: JSON.stringify({ gameVersion: getGameVersion(), + settings: normalizeHighScoreSettings(settings), startedAt: Date.now(), }), }); @@ -251,7 +259,8 @@ export const saveHighScore = ( name: string, score: number, stats: string[], - run?: HighScoreRunReceipt | null + run?: HighScoreRunReceipt | null, + settings?: HighScoreSettings ): HighScoreEntry => { const shouldSync = Boolean(run && canUseHighScoreApi()); @@ -263,6 +272,7 @@ export const saveHighScore = ( name: normalizeHighScoreName(name), run: shouldSync ? run ?? undefined : undefined, score: Math.max(0, Math.floor(score)), + settings: normalizeHighScoreSettings(settings), stats: stats.slice(0, maxHighScoreStats), submittedAt: createdAt, syncState: shouldSync ? "pending" : "local", @@ -436,6 +446,7 @@ const submitPendingScores = async (): Promise => { record.createdAt = storedRemoteEntry.createdAt; record.name = storedRemoteEntry.name; record.score = Math.max(0, Math.floor(storedRemoteEntry.score)); + record.settings = normalizeHighScoreSettings(storedRemoteEntry.settings); record.stats = storedRemoteEntry.stats.slice(0, maxHighScoreStats); record.integrity = undefined; record.receivedAt = storedRemoteEntry.receivedAt ?? Date.now(); @@ -552,6 +563,7 @@ const normalizeStoredEntry = ( typeof stored.receivedAt === "number" ? stored.receivedAt : undefined, run: isHighScoreRunReceipt(stored.run) ? stored.run : undefined, score: Math.max(0, Math.floor(entry.score)), + settings: normalizeHighScoreSettings(stored.settings), stats: entry.stats.slice(0, maxHighScoreStats), integrity: isHighScoreIntegrity(stored.integrity) ? stored.integrity @@ -651,6 +663,7 @@ const createHighScoreIntegrityChecksum = ( entry.id, entry.name, Math.max(0, Math.floor(entry.score)), + formatHighScoreSettingsForIntegrity(entry.settings), entry.stats.join("\n"), entry.submittedAt, multiplier, @@ -719,9 +732,35 @@ const toHighScoreEntry = (entry: HighScoreEntry): HighScoreEntry => ({ id: entry.id, name: entry.name, score: entry.score, + ...(entry.settings ? { settings: entry.settings } : {}), stats: entry.stats, }); +const normalizeHighScoreSettings = ( + value: unknown +): HighScoreSettings | undefined => { + if (!value || typeof value !== "object") { + return undefined; + } + + const settings = value as Partial; + + return { + gameSpeed: normalizeGameSpeed(settings.gameSpeed), + renderFps: normalizeRenderFps(settings.renderFps), + }; +}; + +const formatHighScoreSettingsForIntegrity = ( + settings?: HighScoreSettings +): string => + settings + ? JSON.stringify({ + gameSpeed: settings.gameSpeed, + renderFps: settings.renderFps, + }) + : ""; + const getStorage = (): Storage | null => { try { return typeof localStorage === "undefined" ? null : localStorage; @@ -789,6 +828,8 @@ const isHighScoreEntry = (value: unknown): value is HighScoreEntry => { typeof candidate.id === "string" && typeof candidate.name === "string" && typeof candidate.score === "number" && + (candidate.settings === undefined || + normalizeHighScoreSettings(candidate.settings) !== undefined) && Array.isArray(candidate.stats) && candidate.stats.every((stat) => typeof stat === "string") ); diff --git a/src/game/index.ts b/src/game/index.ts index 7472ee3..628c05f 100644 --- a/src/game/index.ts +++ b/src/game/index.ts @@ -52,6 +52,7 @@ import type { EnemyInstance, GameDataStore, LevelProgressState, + PendingHighScoreEntry, PlayerData, RenderingSystemInstance, RunStats, @@ -255,7 +256,7 @@ export class TimePilot { private isDemoMode = false; private isDebugLevelPreviewLocked = false; private isRemoteHighScoreDisqualified = false; - private pendingHighScore: { score: number; stats: string[] } | null = null; + private pendingHighScore: PendingHighScoreEntry | null = null; private highScoreRunReceipt: HighScoreRunReceipt = null; private highScoreRunRequestId = 0; private hasPlayedHighScoreSound = false; @@ -404,6 +405,14 @@ export class TimePilot { this.context._gameTicker.setFixedStepFps(this.getEffectiveGameTickRate()); }; + private getHighScoreSettings = () => ({ + gameSpeed: userOptions.gameSpeed, + renderFps: userOptions.renderFps, + }); + + private formatHighScoreFps = (fps: typeof userOptions.renderFps): string => + fps === "max" ? "Max" : `${fps}`; + pauseGame = (forcePause?: boolean): void => { if (this.context._gameTicker.isRunning || !!forcePause) { logger.info("Pausing game"); @@ -1912,6 +1921,7 @@ export class TimePilot { this.pendingHighScore = playerData.score > 0 && playerData.continues <= 0 ? { + settings: this.getHighScoreSettings(), score: playerData.score, stats: this.createHighScoreStats(playerData), } @@ -1943,6 +1953,8 @@ export class TimePilot { return [ `Era: ${Math.max(this.context._level, stats.highestLevelReached)}`, `Accuracy: ${accuracy}%`, + `Game speed: ${userOptions.gameSpeed.toFixed(2)}x`, + `FPS: ${this.formatHighScoreFps(userOptions.renderFps)}`, `Near misses: ${stats.nearMisses}`, `Loops: ${stats.loops}`, `Restarts: ${stats.restarts}`, @@ -1970,7 +1982,8 @@ export class TimePilot { name, this.pendingHighScore.score, this.pendingHighScore.stats, - this.canSubmitRemoteHighScore() ? this.highScoreRunReceipt : null + this.canSubmitRemoteHighScore() ? this.highScoreRunReceipt : null, + this.pendingHighScore.settings ); logger.info("Saved high score", { name, @@ -1988,7 +2001,7 @@ export class TimePilot { return; } - const receipt = await startHighScoreRun(); + const receipt = await startHighScoreRun(this.getHighScoreSettings()); if (requestId === this.highScoreRunRequestId && this.canSubmitRemoteHighScore()) { this.highScoreRunReceipt = receipt; diff --git a/src/game/types.ts b/src/game/types.ts index f516496..faaab1a 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -582,10 +582,17 @@ export interface HighScoreEntry { id: string; name: string; score: number; + settings?: HighScoreSettings; stats: string[]; } +export interface HighScoreSettings { + gameSpeed: number; + renderFps: RenderFps; +} + export interface PendingHighScoreEntry { + settings?: HighScoreSettings; score: number; stats: string[]; } From 679656c2b3989152fc8fa492f78cad3c4baabb0a Mon Sep 17 00:00:00 2001 From: Rob Taylor <373278+manix84@users.noreply.github.com> Date: Sat, 30 May 2026 23:59:24 +0100 Subject: [PATCH 04/10] Refactor sound engine imports and enhance configuration options for better modularity --- package-lock.json | 4 +- package.json | 2 +- src/game/bullet-factory.ts | 2 +- src/game/bullet.ts | 2 +- src/game/enemy.ts | 2 +- src/game/engine/Sound.ts | 67 +++++++----- src/game/engine/Ticker.ts | 2 +- src/game/engine/__tests__/engine.test.ts | 54 ++++++++-- src/game/engine/arena.ts | 41 ++++--- src/game/engine/helpers.ts | 2 +- src/game/engine/index.ts | 19 ++++ src/game/engine/types.ts | 132 +++++++++++++++++++++++ src/game/index.ts | 26 ++++- src/game/player.ts | 2 +- src/game/systems/spawning.ts | 2 +- src/game/types.ts | 132 ++++------------------- 16 files changed, 315 insertions(+), 176 deletions(-) create mode 100644 src/game/engine/index.ts create mode 100644 src/game/engine/types.ts diff --git a/package-lock.json b/package-lock.json index 0b6264e..b95e1e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "time-pilot", - "version": "27.0.0", + "version": "28.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "time-pilot", - "version": "27.0.0", + "version": "28.0.0", "dependencies": { "classnames": "^2.5.1", "pg": "^8.21.0", diff --git a/package.json b/package.json index 51f38ee..805c37e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "time-pilot", "private": true, - "version": "27.0.0", + "version": "28.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/game/bullet-factory.ts b/src/game/bullet-factory.ts index 85515ed..e482c42 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 "./engine"; import type { BulletData, BulletFactoryInstance, diff --git a/src/game/bullet.ts b/src/game/bullet.ts index e716b15..cedec06 100644 --- a/src/game/bullet.ts +++ b/src/game/bullet.ts @@ -1,7 +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 { Sound as SoundEngine } from "./engine"; import helpers from "./engine/helpers"; import palette from "./palette"; import { getDespawnRadius } from "./viewport"; diff --git a/src/game/enemy.ts b/src/game/enemy.ts index 4b702ab..0e9dfda 100644 --- a/src/game/enemy.ts +++ b/src/game/enemy.ts @@ -2,7 +2,7 @@ import { levels, scoring } from "./constants"; import userOptions from "./user-options"; import { drawDebugVectors } from "./debug-vectors"; -import SoundEngine from "./engine/Sound"; +import { Sound as SoundEngine } from "./engine"; import helpers from "./engine/helpers"; import palette from "./palette"; import { getDespawnRadius } from "./viewport"; diff --git a/src/game/engine/Sound.ts b/src/game/engine/Sound.ts index f5febf8..bf7f77b 100644 --- a/src/game/engine/Sound.ts +++ b/src/game/engine/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/src/game/engine/Ticker.ts index 8a531aa..55b6600 100644 --- a/src/game/engine/Ticker.ts +++ b/src/game/engine/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/src/game/engine/__tests__/engine.test.ts index d2c8fa9..32784cf 100644 --- a/src/game/engine/__tests__/engine.test.ts +++ b/src/game/engine/__tests__/engine.test.ts @@ -1,19 +1,42 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import userOptions from "../../user-options"; 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(); }); @@ -290,9 +313,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 +330,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 +382,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 +401,8 @@ describe("engine modules", () => { sound.fadeOutAndDestroy(700); Sound.stopAll(); - userOptions.setOption("musicVolume", 2); + volumeSettings.music = 2; + dispatchSoundOptionsChanged(); expect(element.volume).toBeCloseTo(0.5); }); diff --git a/src/game/engine/arena.ts b/src/game/engine/arena.ts index 67c4de4..4e830e2 100644 --- a/src/game/engine/arena.ts +++ b/src/game/engine/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/src/game/engine/helpers.ts b/src/game/engine/helpers.ts index 96beec1..d77d893 100644 --- a/src/game/engine/helpers.ts +++ b/src/game/engine/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/src/game/engine/index.ts b/src/game/engine/index.ts new file mode 100644 index 0000000..86c87f7 --- /dev/null +++ b/src/game/engine/index.ts @@ -0,0 +1,19 @@ +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 type { + AssetProgress, + CircleOptions, + Coordinates, + GameArenaInstance, + GameArenaOptions, + Heading, + PositionedRadius, + RenderTextOptions, + SoundChannel, + SoundEngineConfiguration, + SoundPlaybackBlockedDetails, + SpriteFrame, + TickerInstance, +} from "./types"; diff --git a/src/game/engine/types.ts b/src/game/engine/types.ts new file mode 100644 index 0000000..933a94b --- /dev/null +++ b/src/game/engine/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/src/game/index.ts b/src/game/index.ts index 628c05f..4f775f8 100644 --- a/src/game/index.ts +++ b/src/game/index.ts @@ -10,9 +10,7 @@ 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 "./engine"; import { gameTickRate } from "./game-timing"; import { getHighScores, @@ -26,6 +24,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 +111,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 +486,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..5193de8 100644 --- a/src/game/player.ts +++ b/src/game/player.ts @@ -2,7 +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 { Sound as SoundEngine } from "./engine"; import helpers from "./engine/helpers"; import palette from "./palette"; import type { diff --git a/src/game/systems/spawning.ts b/src/game/systems/spawning.ts index ab7c40b..77b1ef2 100644 --- a/src/game/systems/spawning.ts +++ b/src/game/systems/spawning.ts @@ -1,5 +1,5 @@ import { levels, limits, sounds } from "../constants"; -import SoundEngine from "../engine/Sound"; +import { Sound as SoundEngine } from "../engine"; import helpers from "../engine/helpers"; import type { Coordinates, diff --git a/src/game/types.ts b/src/game/types.ts index faaab1a..26449b7 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -1,65 +1,24 @@ -/** - * 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 "./engine/types"; +export type { + AssetProgress, + CircleOptions, + Coordinates, + GameArenaInstance, + GameArenaOptions, + Heading, + PositionedRadius, + RenderTextOptions, + SoundChannel, + SoundEngineConfiguration, + SoundPlaybackBlockedDetails, + SpriteFrame, + TickerInstance, +} from "./engine/types"; /** * Mutable player state stored in the game data context. @@ -200,55 +159,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; From 29e9503344ff71866d76bdc1bfdee8819fd10d1d Mon Sep 17 00:00:00 2001 From: Rob Taylor <373278+manix84@users.noreply.github.com> Date: Sun, 31 May 2026 00:03:23 +0100 Subject: [PATCH 05/10] Enhance high score integrity checks and add support for game speed and render FPS settings --- package-lock.json | 4 +- package.json | 2 +- server/high-score-server.mjs | 42 ++++++++-- src/game/__tests__/high-scores.test.ts | 107 +++++++++++++++++++++++++ src/game/high-scores.ts | 24 +++++- src/game/menus.ts | 2 +- 6 files changed, 165 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0b6264e..460b761 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "time-pilot", - "version": "27.0.0", + "version": "27.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "time-pilot", - "version": "27.0.0", + "version": "27.1.0", "dependencies": { "classnames": "^2.5.1", "pg": "^8.21.0", diff --git a/package.json b/package.json index 51f38ee..3aef7b0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "time-pilot", "private": true, - "version": "27.0.0", + "version": "27.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server/high-score-server.mjs b/server/high-score-server.mjs index 17b409e..72b3d26 100644 --- a/server/high-score-server.mjs +++ b/server/high-score-server.mjs @@ -34,6 +34,8 @@ const maxScores = 100; const maxPublicScores = 25; const maxPortAttempts = 20; const runReceiptTtlMs = 6 * 60 * 60 * 1000; +const supportedGameSpeeds = [0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 2]; +const supportedRenderFps = [30, 40, 50, 60, 75, 90, 120, 144, "max"]; const loadEnvFiles = () => { for (const filePath of envFilePaths) { @@ -668,7 +670,7 @@ const isValidHighScoreIntegrity = (entry, integrity, run, submittedAt) => { return ( integrity.scoreProduct === expectedScoreProduct && integrity.statsProduct === expectedStatsProduct && - integrity.checksum === + [ createHighScoreIntegrityChecksum( entry, run, @@ -676,7 +678,21 @@ const isValidHighScoreIntegrity = (entry, integrity, run, submittedAt) => { integrity.multiplier, integrity.scoreProduct, integrity.statsProduct - ) + ), + ...(entry.settings + ? [] + : [ + createHighScoreIntegrityChecksum( + entry, + run, + submittedAt, + integrity.multiplier, + integrity.scoreProduct, + integrity.statsProduct, + { includeSettings: false } + ), + ]), + ].includes(integrity.checksum) ); }; @@ -703,7 +719,8 @@ const createHighScoreIntegrityChecksum = ( submittedAt, multiplier, scoreProduct, - statsProduct + statsProduct, + options = {} ) => hashText( [ @@ -714,7 +731,9 @@ const createHighScoreIntegrityChecksum = ( entry.id, entry.name, Math.max(0, Math.floor(entry.score)), - formatScoreSettingsForIntegrity(entry.settings), + ...(options.includeSettings === false + ? [] + : [formatScoreSettingsForIntegrity(entry.settings)]), entry.stats.join("\n"), submittedAt, multiplier, @@ -806,7 +825,7 @@ const isPublicScore = (value) => (value.createdAt === undefined || typeof value.createdAt === "number") && typeof value.name === "string" && typeof value.score === "number" && - (value.settings === undefined || normalizeScoreSettings(value.settings)) && + (value.settings === undefined || isSupportedScoreSettings(value.settings)) && Array.isArray(value.stats) && value.stats.length <= maxScoreStats && value.stats.every((stat) => typeof stat === "string" && stat.length <= 80); @@ -827,18 +846,25 @@ const normalizeScoreSettings = (value) => { } const gameSpeed = - typeof value.gameSpeed === "number" && Number.isFinite(value.gameSpeed) + typeof value.gameSpeed === "number" && + supportedGameSpeeds.includes(value.gameSpeed) ? value.gameSpeed : 1; const renderFps = - value.renderFps === "max" || - (typeof value.renderFps === "number" && Number.isFinite(value.renderFps)) + supportedRenderFps.includes(value.renderFps) ? value.renderFps : "max"; return { gameSpeed, renderFps }; }; +const isSupportedScoreSettings = (value) => + !!value && + typeof value === "object" && + typeof value.gameSpeed === "number" && + supportedGameSpeeds.includes(value.gameSpeed) && + supportedRenderFps.includes(value.renderFps); + const formatScoreSettingsForIntegrity = (settings) => settings ? JSON.stringify({ diff --git a/src/game/__tests__/high-scores.test.ts b/src/game/__tests__/high-scores.test.ts index 9e3398e..f299651 100644 --- a/src/game/__tests__/high-scores.test.ts +++ b/src/game/__tests__/high-scores.test.ts @@ -16,6 +16,55 @@ const createJsonResponse = (body: unknown) => ok: true, }) as unknown as Response; +const hashText = (text: string): number => { + let hash = 2166136261; + + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + + return hash >>> 0; +}; + +const createLegacyIntegrity = ( + entry: { + id: string; + name: string; + score: number; + stats: string[]; + submittedAt: number; + }, + run: { issuedAt: number; runId: string; token: string } +) => { + const multiplier = 101; + const scoreProduct = Math.max(0, Math.floor(entry.score)) * multiplier; + const statsProduct = (hashText(entry.stats.join("\n")) % 1000003) * multiplier; + + return { + checksum: hashText( + [ + 1, + run.runId, + run.token, + run.issuedAt, + entry.id, + entry.name, + Math.max(0, Math.floor(entry.score)), + entry.stats.join("\n"), + entry.submittedAt, + multiplier, + scoreProduct, + statsProduct, + ].join("|") + ).toString(36), + multiplier, + scoreProduct, + statsProduct, + version: 1, + }; +}; + describe("high score storage", () => { beforeEach(() => { localStorage.clear(); @@ -450,6 +499,64 @@ describe("high score storage", () => { expect(storedScores[0]?.run).toBeUndefined(); }); + it("submits legacy pending scores whose v1 integrity predates timing settings", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation( + (input, init) => { + if (input === "/api/high-scores" && init?.method === "POST") { + return Promise.resolve( + createJsonResponse({ + id: "legacy-remote-score", + createdAt: 2000, + name: "Legacy", + receivedAt: 3000, + score: 5000, + stats: ["Era: 1910"], + }) + ); + } + + return Promise.resolve(createJsonResponse([])); + } + ); + const run = { + issuedAt: 1000, + runId: "run-legacy", + token: "receipt-token", + }; + const entry = { + id: "legacy-score", + createdAt: 2000, + name: "Legacy", + run, + score: 5000, + stats: ["Era: 1910"], + submittedAt: 2000, + syncState: "pending", + }; + + localStorage.setItem( + highScoreStorageKey, + JSON.stringify([ + { + ...entry, + integrity: createLegacyIntegrity(entry, run), + }, + ]) + ); + + await syncHighScores(); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/high-scores", + expect.objectContaining({ method: "POST" }) + ); + expect(loadStoredHighScores()[0]).toMatchObject({ + id: "legacy-remote-score", + name: "Legacy", + score: 5000, + }); + }); + it("persists successful pending sync updates before a later submit fails", async () => { const remoteEntry = { id: "remote-score", diff --git a/src/game/high-scores.ts b/src/game/high-scores.ts index ac93788..58604a0 100644 --- a/src/game/high-scores.ts +++ b/src/game/high-scores.ts @@ -636,14 +636,27 @@ const isValidHighScoreIntegrity = ( return ( entry.integrity.scoreProduct === expectedScoreProduct && entry.integrity.statsProduct === expectedStatsProduct && - entry.integrity.checksum === + [ createHighScoreIntegrityChecksum( entry, run, entry.integrity.multiplier, entry.integrity.scoreProduct, entry.integrity.statsProduct - ) + ), + ...(entry.settings + ? [] + : [ + createHighScoreIntegrityChecksum( + entry, + run, + entry.integrity.multiplier, + entry.integrity.scoreProduct, + entry.integrity.statsProduct, + { includeSettings: false } + ), + ]), + ].includes(entry.integrity.checksum) ); }; @@ -652,7 +665,8 @@ const createHighScoreIntegrityChecksum = ( run: HighScoreRunReceipt, multiplier: number, scoreProduct: number, - statsProduct: number + statsProduct: number, + options: { includeSettings?: boolean } = {} ): string => hashText( [ @@ -663,7 +677,9 @@ const createHighScoreIntegrityChecksum = ( entry.id, entry.name, Math.max(0, Math.floor(entry.score)), - formatHighScoreSettingsForIntegrity(entry.settings), + ...(options.includeSettings === false + ? [] + : [formatHighScoreSettingsForIntegrity(entry.settings)]), entry.stats.join("\n"), entry.submittedAt, multiplier, diff --git a/src/game/menus.ts b/src/game/menus.ts index 2850e6d..b385f37 100644 --- a/src/game/menus.ts +++ b/src/game/menus.ts @@ -2206,7 +2206,7 @@ class Menus implements MenuSystemInstance { }); y += 30; - highScore.stats.slice(0, 12).forEach((stat) => { + highScore.stats.forEach((stat) => { this._wrapText(stat, wrapWidth).forEach((line) => { this._gameArena.renderText(line, x, y, { size: 10, From b0f76cc8532263dce9af73ea1145edc40e67bff4 Mon Sep 17 00:00:00 2001 From: Rob Taylor <373278+manix84@users.noreply.github.com> Date: Sun, 31 May 2026 00:15:25 +0100 Subject: [PATCH 06/10] Refactor engine imports to enhance modularity and maintainability across game modules --- package-lock.json | 4 ++-- package.json | 2 +- src/game/__tests__/module-imports.test.ts | 21 +++++++++++++++++++++ src/game/bonus.ts | 2 +- src/game/bullet.ts | 3 +-- src/game/controller/gamepad.ts | 2 +- src/game/controller/keyboard1.ts | 2 +- src/game/controller/keyboard2.ts | 2 +- src/game/controller/touch.ts | 2 +- src/game/enemy.ts | 3 +-- src/game/player.ts | 3 +-- src/game/prop.ts | 2 +- src/game/systems/collision.ts | 2 +- src/game/systems/spawning.ts | 3 +-- 14 files changed, 35 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 26304c4..9141119 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "time-pilot", - "version": "28.1.0", + "version": "28.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "time-pilot", - "version": "28.1.0", + "version": "28.2.0", "dependencies": { "classnames": "^2.5.1", "pg": "^8.21.0", diff --git a/package.json b/package.json index 93b2f45..4f6c51f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "time-pilot", "private": true, - "version": "28.1.0", + "version": "28.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/game/__tests__/module-imports.test.ts b/src/game/__tests__/module-imports.test.ts index 67fbc7c..34b3eca 100644 --- a/src/game/__tests__/module-imports.test.ts +++ b/src/game/__tests__/module-imports.test.ts @@ -21,6 +21,15 @@ import Prop from "../prop"; import PropFactory from "../prop-factory"; import userOptions from "../user-options"; +const engineSourceFiles = import.meta.glob("../engine/**/*.ts", { + eager: true, + import: "default", + query: "?raw", +}); + +const getStaticImportSpecifiers = (source: string): string[] => + Array.from(source.matchAll(/\bfrom\s+["']([^"']+)["']/g), (match) => match[1]); + describe("game module imports", () => { it("loads every game module", () => { expect(Bonus).toBeTypeOf("function"); @@ -95,4 +104,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..7bee30b 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 "./engine"; import palette from "./palette"; import userOptions from "./user-options"; import { getDespawnRadius } from "./viewport"; diff --git a/src/game/bullet.ts b/src/game/bullet.ts index cedec06..776ee8d 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 { Sound as SoundEngine } from "./engine"; -import helpers from "./engine/helpers"; +import { helpers, Sound as SoundEngine } from "./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..ca8e8ac 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 "../engine"; import type { ControlInputName, ControlInputState, diff --git a/src/game/controller/keyboard1.ts b/src/game/controller/keyboard1.ts index 3b8161c..97cbf3d 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 "../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..dae0587 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 "../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..8477a7c 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 "../engine"; import { clampZoomPercent, zoomStepPercent } from "../ui-scale"; import type { ControlInputName, diff --git a/src/game/enemy.ts b/src/game/enemy.ts index 0e9dfda..4df7f7c 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 { Sound as SoundEngine } from "./engine"; -import helpers from "./engine/helpers"; +import { helpers, Sound as SoundEngine } from "./engine"; import palette from "./palette"; import { getDespawnRadius } from "./viewport"; import type { diff --git a/src/game/player.ts b/src/game/player.ts index 5193de8..046922a 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 { Sound as SoundEngine } from "./engine"; -import helpers from "./engine/helpers"; +import { helpers, Sound as SoundEngine } from "./engine"; import palette from "./palette"; import type { BulletFactoryInstance, diff --git a/src/game/prop.ts b/src/game/prop.ts index 93ae777..377901c 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 "./engine"; import { getDespawnRadius } from "./viewport"; import type { GameArenaInstance, diff --git a/src/game/systems/collision.ts b/src/game/systems/collision.ts index b9681f7..e216027 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 "../engine"; import type { BulletData, BulletInstance, diff --git a/src/game/systems/spawning.ts b/src/game/systems/spawning.ts index 77b1ef2..3788613 100644 --- a/src/game/systems/spawning.ts +++ b/src/game/systems/spawning.ts @@ -1,6 +1,5 @@ import { levels, limits, sounds } from "../constants"; -import { Sound as SoundEngine } from "../engine"; -import helpers from "../engine/helpers"; +import { helpers, Sound as SoundEngine } from "../engine"; import type { Coordinates, EnemyData, From ce299efcf02a1879399b7e444664fa60c8557d08 Mon Sep 17 00:00:00 2001 From: Rob Taylor <373278+manix84@users.noreply.github.com> Date: Sun, 31 May 2026 00:20:40 +0100 Subject: [PATCH 07/10] Add test for notifying consumers when sound playback is blocked --- package-lock.json | 4 ++-- package.json | 2 +- src/game/engine/__tests__/engine.test.ts | 29 ++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9141119..2b81f2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "time-pilot", - "version": "28.2.0", + "version": "28.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "time-pilot", - "version": "28.2.0", + "version": "28.3.0", "dependencies": { "classnames": "^2.5.1", "pg": "^8.21.0", diff --git a/package.json b/package.json index 4f6c51f..1e7d6f1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "time-pilot", "private": true, - "version": "28.2.0", + "version": "28.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/game/engine/__tests__/engine.test.ts b/src/game/engine/__tests__/engine.test.ts index 32784cf..f609d83 100644 --- a/src/game/engine/__tests__/engine.test.ts +++ b/src/game/engine/__tests__/engine.test.ts @@ -427,6 +427,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, From 3a8b45a832bfac446eedd39bb561db928aabea9a Mon Sep 17 00:00:00 2001 From: Rob Taylor <373278+manix84@users.noreply.github.com> Date: Sun, 31 May 2026 00:23:25 +0100 Subject: [PATCH 08/10] Implement engine separation by refactoring debug vector drawing and viewport calculations --- package-lock.json | 4 +- package.json | 2 +- src/game/debug-vectors.ts | 86 ++------------------- src/game/engine/__tests__/engine.test.ts | 57 ++++++++++++++ src/game/engine/debug-vectors.ts | 97 ++++++++++++++++++++++++ src/game/engine/index.ts | 13 ++++ src/game/engine/viewport.ts | 66 ++++++++++++++++ src/game/types.ts | 7 ++ src/game/viewport.ts | 52 ++++++------- 9 files changed, 277 insertions(+), 107 deletions(-) create mode 100644 src/game/engine/debug-vectors.ts create mode 100644 src/game/engine/viewport.ts diff --git a/package-lock.json b/package-lock.json index 2b81f2f..26a8a3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "time-pilot", - "version": "28.3.0", + "version": "29.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "time-pilot", - "version": "28.3.0", + "version": "29.0.0", "dependencies": { "classnames": "^2.5.1", "pg": "^8.21.0", diff --git a/package.json b/package.json index 1e7d6f1..c53d5b1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "time-pilot", "private": true, - "version": "28.3.0", + "version": "29.0.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/game/debug-vectors.ts b/src/game/debug-vectors.ts index baac578..d137504 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 "./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/engine/__tests__/engine.test.ts b/src/game/engine/__tests__/engine.test.ts index f609d83..cbc1641 100644 --- a/src/game/engine/__tests__/engine.test.ts +++ b/src/game/engine/__tests__/engine.test.ts @@ -1,4 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + drawDebugVectors, + getScaledViewportLimit, + getViewportAreaScale, + getViewportPaddedRadius, + getViewportRadius, +} from "../index"; import GameArena from "../arena"; import Sound from "../Sound"; import Ticker from "../Ticker"; @@ -214,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(); diff --git a/src/game/engine/debug-vectors.ts b/src/game/engine/debug-vectors.ts new file mode 100644 index 0000000..75e0827 --- /dev/null +++ b/src/game/engine/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/index.ts b/src/game/engine/index.ts index 86c87f7..1c7fe7d 100644 --- a/src/game/engine/index.ts +++ b/src/game/engine/index.ts @@ -1,7 +1,15 @@ +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, @@ -17,3 +25,8 @@ export type { SpriteFrame, TickerInstance, } from "./types"; +export type { + ViewportAreaScaleOptions, + ViewportDimensions, + ViewportRadiusOptions, +} from "./viewport"; diff --git a/src/game/engine/viewport.ts b/src/game/engine/viewport.ts new file mode 100644 index 0000000..64ece13 --- /dev/null +++ b/src/game/engine/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/src/game/types.ts b/src/game/types.ts index 26449b7..3c9d181 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -19,6 +19,13 @@ export type { SpriteFrame, TickerInstance, } from "./engine/types"; +export type { + DebugVectorColors, + DebugVectorOptions, + ViewportAreaScaleOptions, + ViewportDimensions, + ViewportRadiusOptions, +} from "./engine"; /** * Mutable player state stored in the game data context. diff --git a/src/game/viewport.ts b/src/game/viewport.ts index 343d979..290ae8c 100644 --- a/src/game/viewport.ts +++ b/src/game/viewport.ts @@ -1,18 +1,16 @@ import { limits } from "./constants"; -import type { GameArenaInstance } from "./types"; +import { + getScaledViewportLimit, + getViewportAreaScale as getEngineViewportAreaScale, + getViewportPaddedRadius, + getViewportRadius, +} from "./engine"; +import type { GameArenaInstance, 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 "./engine"; /** * Calculates the distance from center where entities can spawn safely off-screen. @@ -20,11 +18,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 +31,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 +41,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 +52,11 @@ export const getScaledEntityLimit = (baseLimit: number, gameArena: Pick): number => { - return Math.max( - limits.despawnRadius, - getViewportRadius(gameArena) + despawnPadding - ); +export const getDespawnRadius = ( + gameArena: Pick +): number => { + return getViewportPaddedRadius(gameArena, { + minRadius: limits.despawnRadius, + padding: despawnPadding, + }); }; From 40d75042935adc700f2edbec6ac376e1892e6c58 Mon Sep 17 00:00:00 2001 From: Rob Taylor <373278+manix84@users.noreply.github.com> Date: Sun, 31 May 2026 00:34:05 +0100 Subject: [PATCH 09/10] feat: add debug vector drawing and helper functions - Introduced `debug-vectors.ts` for drawing heading and steering vectors on canvas. - Added `helpers.ts` with utility functions for collision detection, angle rotation, and random color generation. - Created `types.ts` to define common types such as `Heading`, `Coordinates`, and `PositionedRadius`. - Implemented viewport utilities in `viewport.ts` for calculating viewport dimensions and scaling. - Updated `index.ts` to export new modules and types. - Refactored game files to utilize the new arcade engine module imports. - Adjusted TypeScript configuration to include new paths and aliases for the arcade engine. --- eslint.config.js | 2 +- package-lock.json | 4 ++-- package.json | 2 +- packages/arcade-engine/package.json | 10 ++++++++++ .../engine => packages/arcade-engine/src}/Sound.ts | 0 .../engine => packages/arcade-engine/src}/Ticker.ts | 0 .../arcade-engine/src}/__tests__/engine.test.ts | 0 .../arcade-engine/src}/__tests__/helpers.test.ts | 0 .../engine => packages/arcade-engine/src}/arena.ts | 0 .../arcade-engine/src}/debug-vectors.ts | 0 .../arcade-engine/src}/helpers.ts | 0 .../engine => packages/arcade-engine/src}/index.ts | 0 .../engine => packages/arcade-engine/src}/types.ts | 0 .../arcade-engine/src}/viewport.ts | 0 packages/arcade-engine/tsconfig.json | 7 +++++++ src/game/__tests__/module-imports.test.ts | 13 ++++++++----- src/game/bonus.ts | 2 +- src/game/bullet-factory.ts | 2 +- src/game/bullet.ts | 2 +- src/game/controller/gamepad.ts | 2 +- src/game/controller/keyboard1.ts | 2 +- src/game/controller/keyboard2.ts | 2 +- src/game/controller/touch.ts | 2 +- src/game/debug-vectors.ts | 2 +- src/game/enemy.ts | 2 +- src/game/index.ts | 6 +++++- src/game/player.ts | 2 +- src/game/prop.ts | 2 +- src/game/systems/collision.ts | 2 +- src/game/systems/spawning.ts | 2 +- src/game/types.ts | 6 +++--- src/game/viewport.ts | 4 ++-- src/stories/Audio.stories.tsx | 2 +- src/stories/Palette.stories.tsx | 2 +- src/stories/Ticker.stories.tsx | 2 +- tsconfig.json | 6 +++++- vite.config.ts | 8 ++++++++ 37 files changed, 68 insertions(+), 32 deletions(-) create mode 100644 packages/arcade-engine/package.json rename {src/game/engine => packages/arcade-engine/src}/Sound.ts (100%) rename {src/game/engine => packages/arcade-engine/src}/Ticker.ts (100%) rename {src/game/engine => packages/arcade-engine/src}/__tests__/engine.test.ts (100%) rename {src/game/engine => packages/arcade-engine/src}/__tests__/helpers.test.ts (100%) rename {src/game/engine => packages/arcade-engine/src}/arena.ts (100%) rename {src/game/engine => packages/arcade-engine/src}/debug-vectors.ts (100%) rename {src/game/engine => packages/arcade-engine/src}/helpers.ts (100%) rename {src/game/engine => packages/arcade-engine/src}/index.ts (100%) rename {src/game/engine => packages/arcade-engine/src}/types.ts (100%) rename {src/game/engine => packages/arcade-engine/src}/viewport.ts (100%) create mode 100644 packages/arcade-engine/tsconfig.json 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 26a8a3c..261d578 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "time-pilot", - "version": "29.0.0", + "version": "29.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "time-pilot", - "version": "29.0.0", + "version": "29.1.0", "dependencies": { "classnames": "^2.5.1", "pg": "^8.21.0", diff --git a/package.json b/package.json index c53d5b1..46b2343 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "time-pilot", "private": true, - "version": "29.0.0", + "version": "29.1.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 100% rename from src/game/engine/Sound.ts rename to packages/arcade-engine/src/Sound.ts diff --git a/src/game/engine/Ticker.ts b/packages/arcade-engine/src/Ticker.ts similarity index 100% rename from src/game/engine/Ticker.ts rename to packages/arcade-engine/src/Ticker.ts diff --git a/src/game/engine/__tests__/engine.test.ts b/packages/arcade-engine/src/__tests__/engine.test.ts similarity index 100% rename from src/game/engine/__tests__/engine.test.ts rename to packages/arcade-engine/src/__tests__/engine.test.ts 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 100% rename from src/game/engine/arena.ts rename to packages/arcade-engine/src/arena.ts diff --git a/src/game/engine/debug-vectors.ts b/packages/arcade-engine/src/debug-vectors.ts similarity index 100% rename from src/game/engine/debug-vectors.ts rename to packages/arcade-engine/src/debug-vectors.ts diff --git a/src/game/engine/helpers.ts b/packages/arcade-engine/src/helpers.ts similarity index 100% rename from src/game/engine/helpers.ts rename to packages/arcade-engine/src/helpers.ts diff --git a/src/game/engine/index.ts b/packages/arcade-engine/src/index.ts similarity index 100% rename from src/game/engine/index.ts rename to packages/arcade-engine/src/index.ts diff --git a/src/game/engine/types.ts b/packages/arcade-engine/src/types.ts similarity index 100% rename from src/game/engine/types.ts rename to packages/arcade-engine/src/types.ts diff --git a/src/game/engine/viewport.ts b/packages/arcade-engine/src/viewport.ts similarity index 100% rename from src/game/engine/viewport.ts rename to packages/arcade-engine/src/viewport.ts 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 34b3eca..ccfcfff 100644 --- a/src/game/__tests__/module-imports.test.ts +++ b/src/game/__tests__/module-imports.test.ts @@ -21,11 +21,14 @@ import Prop from "../prop"; import PropFactory from "../prop-factory"; import userOptions from "../user-options"; -const engineSourceFiles = import.meta.glob("../engine/**/*.ts", { - eager: true, - import: "default", - query: "?raw", -}); +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(/\bfrom\s+["']([^"']+)["']/g), (match) => match[1]); diff --git a/src/game/bonus.ts b/src/game/bonus.ts index 7bee30b..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"; +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 e482c42..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 { Sound as SoundEngine } from "./engine"; +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 776ee8d..6d1514c 100644 --- a/src/game/bullet.ts +++ b/src/game/bullet.ts @@ -1,7 +1,7 @@ /* Converted from TimePilot.Bullet.js (AMD) to ESM TypeScript. */ import userOptions from "./user-options"; import { drawDebugVectors } from "./debug-vectors"; -import { helpers, Sound as SoundEngine } from "./engine"; +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 ca8e8ac..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"; +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 97cbf3d..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"; +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 dae0587..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"; +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 8477a7c..04df502 100644 --- a/src/game/controller/touch.ts +++ b/src/game/controller/touch.ts @@ -1,4 +1,4 @@ -import { helpers } from "../engine"; +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 d137504..03205e4 100644 --- a/src/game/debug-vectors.ts +++ b/src/game/debug-vectors.ts @@ -1,5 +1,5 @@ import palette from "./palette"; -import { drawDebugVectors as drawEngineDebugVectors } from "./engine"; +import { drawDebugVectors as drawEngineDebugVectors } from "@time-pilot/arcade-engine"; import type { Heading } from "./types"; interface DebugVectorOptions { diff --git a/src/game/enemy.ts b/src/game/enemy.ts index 4df7f7c..f476c20 100644 --- a/src/game/enemy.ts +++ b/src/game/enemy.ts @@ -2,7 +2,7 @@ import { levels, scoring } from "./constants"; import userOptions from "./user-options"; import { drawDebugVectors } from "./debug-vectors"; -import { helpers, Sound as SoundEngine } from "./engine"; +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 4f775f8..80154e6 100644 --- a/src/game/index.ts +++ b/src/game/index.ts @@ -10,7 +10,11 @@ import Keyboard2 from "./controller/keyboard2"; import Mouse from "./controller/mouse"; import Touch from "./controller/touch"; import EnemyFactory from "./enemy-factory"; -import { GameArena, Sound as SoundEngine, Ticker } from "./engine"; +import { + GameArena, + Sound as SoundEngine, + Ticker, +} from "@time-pilot/arcade-engine"; import { gameTickRate } from "./game-timing"; import { getHighScores, diff --git a/src/game/player.ts b/src/game/player.ts index 046922a..a446c4a 100644 --- a/src/game/player.ts +++ b/src/game/player.ts @@ -2,7 +2,7 @@ import { levels, player, scoring, sounds } from "./constants"; import userOptions from "./user-options"; import { drawDebugVectors } from "./debug-vectors"; -import { helpers, Sound as SoundEngine } from "./engine"; +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 377901c..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"; +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 e216027..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"; +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 3788613..7c5a48e 100644 --- a/src/game/systems/spawning.ts +++ b/src/game/systems/spawning.ts @@ -1,5 +1,5 @@ import { levels, limits, sounds } from "../constants"; -import { helpers, Sound as SoundEngine } from "../engine"; +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 3c9d181..99ae43f 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -3,7 +3,7 @@ import type { GameArenaInstance, Heading, TickerInstance, -} from "./engine/types"; +} from "@time-pilot/arcade-engine"; export type { AssetProgress, CircleOptions, @@ -18,14 +18,14 @@ export type { SoundPlaybackBlockedDetails, SpriteFrame, TickerInstance, -} from "./engine/types"; +} from "@time-pilot/arcade-engine"; export type { DebugVectorColors, DebugVectorOptions, ViewportAreaScaleOptions, ViewportDimensions, ViewportRadiusOptions, -} from "./engine"; +} from "@time-pilot/arcade-engine"; /** * Mutable player state stored in the game data context. diff --git a/src/game/viewport.ts b/src/game/viewport.ts index 290ae8c..6a66af7 100644 --- a/src/game/viewport.ts +++ b/src/game/viewport.ts @@ -4,13 +4,13 @@ import { getViewportAreaScale as getEngineViewportAreaScale, getViewportPaddedRadius, getViewportRadius, -} from "./engine"; +} from "@time-pilot/arcade-engine"; import type { GameArenaInstance, ViewportDimensions } from "./types"; const spawnPadding = 96; const despawnPadding = 160; -export { getViewportRadius } from "./engine"; +export { getViewportRadius } from "@time-pilot/arcade-engine"; /** * Calculates the distance from center where entities can spawn safely off-screen. 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, From ea7e3b58e4a00523986e86096534db44ba802802 Mon Sep 17 00:00:00 2001 From: Rob Taylor <373278+manix84@users.noreply.github.com> Date: Sun, 31 May 2026 00:35:39 +0100 Subject: [PATCH 10/10] refactor: improve import specifier extraction and update viewport type usage --- package-lock.json | 4 ++-- package.json | 2 +- src/game/__tests__/module-imports.test.ts | 5 ++++- src/game/viewport.ts | 5 ++--- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 261d578..ff6e59a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "time-pilot", - "version": "29.1.0", + "version": "29.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "time-pilot", - "version": "29.1.0", + "version": "29.2.0", "dependencies": { "classnames": "^2.5.1", "pg": "^8.21.0", diff --git a/package.json b/package.json index 46b2343..0051e47 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "time-pilot", "private": true, - "version": "29.1.0", + "version": "29.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/game/__tests__/module-imports.test.ts b/src/game/__tests__/module-imports.test.ts index ccfcfff..97dd641 100644 --- a/src/game/__tests__/module-imports.test.ts +++ b/src/game/__tests__/module-imports.test.ts @@ -31,7 +31,10 @@ const engineSourceFiles = import.meta.glob( ); const getStaticImportSpecifiers = (source: string): string[] => - Array.from(source.matchAll(/\bfrom\s+["']([^"']+)["']/g), (match) => match[1]); + Array.from( + source.matchAll(/\bimport\s+(?:[^"';]+?\s+from\s+)?["']([^"']+)["']/g), + (match) => match[1] + ); describe("game module imports", () => { it("loads every game module", () => { diff --git a/src/game/viewport.ts b/src/game/viewport.ts index 6a66af7..47ca6f1 100644 --- a/src/game/viewport.ts +++ b/src/game/viewport.ts @@ -3,9 +3,8 @@ import { getScaledViewportLimit, getViewportAreaScale as getEngineViewportAreaScale, getViewportPaddedRadius, - getViewportRadius, } from "@time-pilot/arcade-engine"; -import type { GameArenaInstance, ViewportDimensions } from "./types"; +import type { ViewportDimensions } from "./types"; const spawnPadding = 96; const despawnPadding = 160; @@ -53,7 +52,7 @@ export const getScaledEntityLimit = ( * @returns Despawn radius using the larger of the configured minimum and viewport size. */ export const getDespawnRadius = ( - gameArena: Pick + gameArena: ViewportDimensions ): number => { return getViewportPaddedRadius(gameArena, { minRadius: limits.despawnRadius,