import type { BitmapFont } from '../assets/BitmapFont'; import type { Palette } from '../assets/Palette'; import { CycleEffect, FadeEffect, FadeRangeEffect, FlashEffect, PaletteEffectManager, paletteSwap, } from '../assets/PaletteEffect'; import type { SpriteSheet } from '../assets/SpriteSheet'; import { createSystemFont } from '../assets/SystemFont'; import { GamepadInput } from '../input/GamepadInput'; import { KeyboardInput } from '../input/KeyboardInput'; import { PointerInput } from '../input/PointerInput'; import type { Effect } from '../render/effects/Effect'; import type { IRenderer } from '../render/IRenderer'; import { SoftwareRenderer } from '../render/SoftwareRenderer'; import { SoftwareTicker } from '../render/SoftwareTicker'; import { WebGpuRenderer } from '../render/WebGpuRenderer'; import { applyCanvasLayoutStyles, DEFAULT_MAX_CANVAS_DISPLAY_SIZE } from '../utils/CanvasLayoutStyles'; import type { Color32 } from '../utils/Color32'; import type { EasingFunction } from '../utils/Easing'; import { noActivePaletteError, paletteIndexNegativeError, paletteIndexOutOfRangeError, spriteNotIndexizedError, } from '../utils/errorMessages'; import type { Rect2i } from '../utils/Rect2i'; import { RenderDimensionLimitError, validateRenderDimensions } from '../utils/RenderLimits'; import { Vector2i } from '../utils/Vector2i'; import type { FrameDropCallback, FrameDropEvent } from './GameLoop'; import { GameLoop } from './GameLoop'; import type { HardwareSettings, IBlitTechDemo, RendererBackend } from './IBlitTechDemo'; import { defaultConfig, mergeHardwareSettings } from './IBlitTechDemo'; import { initWebGPU } from './WebGPUContext'; /** * Central runtime facade for Blit-Tech engine services. * * `BTAPI` owns engine initialization, keeps references to the active WebGPU * objects and renderer, and exposes the drawing/camera methods used by demos. * It is a singleton; access it through `BTAPI.instance`. */ export class BTAPI { // #region Version Constants /** * Major semantic-version component. */ public static readonly VERSION_MAJOR = 1; /** Minor version number. */ public static readonly VERSION_MINOR = 0; /** Patch version number. */ public static readonly VERSION_PATCH = 4; // #endregion // #region Module State - Demo Instance /** Current demo instance implementing IBlitTechDemo. */ private demo: IBlitTechDemo | null = null; // #endregion // #region Module State - Hardware Settings /** Hardware configuration settings from the demo. */ private hwSettings: HardwareSettings | null = null; /** WebGPU device for GPU operations. */ private device: GPUDevice | null = null; // #endregion // #region Module State - WebGPU Resources /** WebGPU canvas context for presenting frames. */ private context: GPUCanvasContext | null = null; /** HTML canvas element used for rendering. */ private canvas: HTMLCanvasElement | null = null; /** Renderer subsystem for all drawing operations. */ private renderer: IRenderer | null = null; /** Backend that was successfully initialized, or null before init. */ private activeBackend: RendererBackend | null = null; /** Software-mode ticker overlay; non-null only when the software backend is active. */ private ticker: SoftwareTicker | null = null; /** Active engine palette used by palette-first rendering. */ private palette: Palette | null = null; /** Registry of all sprite sheets that have been passed to drawSprite, for spritesRefresh. */ private readonly spriteSheets: Set = new Set(); // #endregion // #region Module State - Subsystems /** Game loop managing fixed-timestep updates and variable-rate rendering. */ private loop: GameLoop | null = null; /** Manages animated palette effects (cycling, fading, flashing). */ private readonly paletteEffects = new PaletteEffectManager(); /** Built-in 6x14 system font for BT.systemPrint(). */ private systemFont: BitmapFont | null = null; /** Pointer / mouse / touch input subsystem. Created during {@link init}. */ private pointer: PointerInput | null = null; /** Keyboard input (VV-134). Created during {@link init}. */ private keyboard: KeyboardInput | null = null; /** Gamepad input (VV-135). Created during {@link init}. */ private gamepad: GamepadInput | null = null; // TODO: Additional subsystems for future implementation: // AudioManager, AssetManager // #endregion // #region Singleton / Constructor /** * Private constructor to enforce singleton access via `BTAPI.instance`. */ private constructor() {} /** Singleton instance of BTAPI. */ private static _instance: BTAPI | null = null; // #endregion // #region Singleton Access /** * Gets the lazily created singleton instance. * * @returns The global BTAPI instance. */ public static get instance(): BTAPI { if (!BTAPI._instance) { BTAPI._instance = new BTAPI(); } return BTAPI._instance; } // #endregion // #region Initialization /** * Initializes the engine for a demo and starts the main loop on success. * * The initialization sequence is: * - read hardware settings from the demo (`configure()` or defaults) * - initialize WebGPU and create the renderer * - run the demo's async `init()` * - start the fixed-timestep game loop * * @param demo - Demo implementing the IBlitTechDemo interface. * @param canvas - HTML canvas element for WebGPU rendering. * @returns `true` when initialization succeeds; otherwise `false`. */ public async init(demo: IBlitTechDemo, canvas: HTMLCanvasElement): Promise { console.log(`[BT] Initializing engine v${BTAPI.VERSION_MAJOR}.${BTAPI.VERSION_MINOR}.${BTAPI.VERSION_PATCH}`); this.demo = demo; this.canvas = canvas; // Hardware settings: demo hook or defaults (320x240 @ 60 FPS). console.log('[BT] Reading hardware configuration'); let configured: Partial | undefined; try { configured = demo.configure?.(); } catch (error) { console.error('[BT] demo.configure() threw; falling back to defaultConfig()', error); this.hwSettings = defaultConfig(); return false; } this.hwSettings = mergeHardwareSettings(configured); this.applyRendererQueryOverride(); const { targetFPS } = this.hwSettings; const renderDimensionError = validateRenderDimensions(this.hwSettings); if (renderDimensionError) { console.error(`[BT] ${renderDimensionError}`); return false; } if (!Number.isFinite(targetFPS) || targetFPS <= 0) { console.error(`[BT] Invalid targetFPS: ${targetFPS}. Must be a finite number > 0.`); return false; } const updateInterval = 1000 / targetFPS; console.log('[BT] Hardware settings:', { displaySize: `${this.hwSettings.displaySize.x}x${this.hwSettings.displaySize.y}`, targetFPS: this.hwSettings.targetFPS, }); if (!(await this.initRenderer(canvas, this.hwSettings))) { return false; } // Create the built-in system font (synchronous, no GPU needed yet). this.systemFont = createSystemFont(); // Initialize pointer input. Listeners attach to the canvas; per-frame // resets are wired to GameLoop.onTickStart below. this.pointer?.detach(); this.pointer = new PointerInput(); this.pointer.attach(canvas, this.hwSettings.displaySize); this.keyboard?.detach(); this.keyboard = new KeyboardInput(); this.keyboard.attach(canvas, { getTicks: () => this.loop?.getTicks() ?? 0, }); this.gamepad?.detach(); this.gamepad = new GamepadInput(); this.gamepad.attach(); // TODO: Initialize audio. // Initialize the demo. console.log('[BT] Initializing demo'); if (!(await this.runDemoInit(demo))) { return false; } // Start the loop. The GameLoop's double-RAF delay ensures the canvas is // fully ready before the first tick. const onFrameDrop: FrameDropCallback | undefined = this.hwSettings.detectDroppedFrames === true ? (event) => this.handleFrameDrop(event) : undefined; this.loop = new GameLoop( updateInterval, () => this.demo?.update(), () => { if (this.renderer) { this.renderer.beginFrame(); this.demo?.render(); // Palette effects run after demo render (so user's explicit palette // changes in render() are respected) but before endFrame (so effects // are visible this frame via the dirty-flag GPU upload). if (this.palette && this.paletteEffects.activeCount > 0) { this.paletteEffects.update(this.palette); } // Software ticker renders on top of the demo, after user draw calls. if (this.ticker && this.systemFont) { this.ticker.render( this.renderer, this.hwSettings?.displaySize.x ?? 0, this.systemFont, this.pointer, ); if (this.ticker.isDismissed) { this.ticker = null; } } this.renderer.endFrame(); } // Snapshot pointer state for next frame's edge detection / delta. // Must run AFTER demo.update + demo.render have read the current // state, so prev = "state when update last looked", letting any // event that arrives before the next tick be visible as a transition. this.pointer?.endFrame(); const tick = this.loop?.getTicks() ?? 0; this.keyboard?.endFrame(tick); this.gamepad?.endFrame(tick); }, onFrameDrop, ); this.loop.start(); console.log('[BT] Initialization complete'); return true; } /** * Stops the active game loop and detaches input subsystems. * * Pointer, keyboard, and gamepad subsystems are detached so listeners and * polling state do not leak across engine restarts (relevant in tests where * the same DOM persists). */ public stop(): void { this.loop?.stop(); this.clearInputSubsystems(); } /** * Gets the current tick count. * Ticks increment once per fixed update step (target rate set by `targetFPS`). * * @returns Number of update ticks since initialization or last reset. */ public getTicks(): number { return this.loop?.getTicks() ?? 0; } /** * Resets the tick counter to zero. * Useful for timing-based demo events and animations. */ public resetTicks(): void { this.loop?.resetTicks(); } // #endregion // #region Demo State Accessors /** * Gets the hardware settings used for the active demo (from `configure()` or * {@link defaultConfig}). * * @returns Hardware configuration, or null if not initialized. */ public getHardwareSettings(): HardwareSettings | null { return this.hwSettings; } /** * Gets the initialized WebGPU device. * * @returns GPU device, or null if not initialized. */ public getDevice(): GPUDevice | null { return this.device; } /** * Gets the configured WebGPU canvas context. * * @returns Canvas context, or null if not initialized. */ public getContext(): GPUCanvasContext | null { return this.context; } // #endregion // #region WebGPU Resource Accessors /** * Gets the canvas bound during initialization. * * @returns HTML canvas element, or null if not initialized. */ public getCanvas(): HTMLCanvasElement | null { return this.canvas; } /** * Gets the renderer created during initialization. * * @returns Renderer instance, or null if not initialized. */ public getRenderer(): IRenderer | null { return this.renderer; } /** * Returns the renderer backend that was actually initialized. * * @returns `'webgpu'` or `'software'` after successful init; `null` before init or on failure. */ public getActiveBackend(): RendererBackend | null { return this.activeBackend; } /** * Gets the pointer input subsystem created during initialization. * * @returns Pointer input instance, or null when the engine has not been * initialized yet (or has been stopped). */ public getPointer(): PointerInput | null { return this.pointer; } /** * Gets the keyboard input subsystem created during initialization. * * @returns Keyboard input instance, or null when the engine has not been * initialized yet (or has been stopped). */ public getKeyboard(): KeyboardInput | null { return this.keyboard; } /** * Gets the gamepad input subsystem created during initialization. * * @returns Gamepad input instance, or null when the engine has not been * initialized yet (or has been stopped). */ public getGamepad(): GamepadInput | null { return this.gamepad; } // #endregion // #region Rendering API - Palette /** * Gets the active engine palette. * * @returns Active palette, or null if none has been set. */ public getPalette(): Palette | null { return this.palette; } /** * Sets the active engine palette and propagates it to the renderer. * * If sprite sheets have already been indexized, emits a warning: the indexed * pixel data will be stale until `spritesRefresh()` is called. * * @param palette - Palette to store as the active engine palette. */ public setPalette(palette: Palette): void { if (this.spriteSheets.size > 0) { console.warn('[BT] Active palette structure changed. Call BT.spritesRefresh() to update loaded sprites.'); } // In-flight effects hold snapshots of the old palette. Drop them so they // don't apply stale colors to the new palette. this.paletteEffects.clear(); this.palette = palette; this.renderer?.setPalette(palette); } // #endregion // #region Rendering API - Clear Operations /** * Sets the background clear color for each frame using a palette index. * * @param paletteIndex - Palette index for the clear color. */ public setClearColor(paletteIndex: number): void { this.assertPaletteIndex(paletteIndex); this.renderer?.setClearColor(paletteIndex); } /** * Fills a rectangular region with a palette-indexed color. * * @param rect - Region to fill in pixel coordinates. * @param paletteIndex - Palette color index. */ public clearRect(rect: Rect2i, paletteIndex: number): void { this.assertPaletteIndex(paletteIndex); this.renderer?.clearRect(rect, paletteIndex); } /** * Draws a single pixel at the specified position. * * @param pos - Pixel coordinates. * @param paletteIndex - Palette color index. */ public drawPixel(pos: Vector2i, paletteIndex: number): void { this.assertPaletteIndex(paletteIndex); this.renderer?.drawPixel(pos, paletteIndex); } /** * Draws a line between two points using Bresenham's algorithm. * Produces pixel-perfect lines without antialiasing. * * @param p0 - Start point. * @param p1 - End point. * @param paletteIndex - Palette color index. */ public drawLine(p0: Vector2i, p1: Vector2i, paletteIndex: number): void { this.assertPaletteIndex(paletteIndex); this.renderer?.drawLine(p0, p1, paletteIndex); } // #endregion // #region Rendering API - Primitives /** * Draws a rectangle outline (unfilled). * * @param rect - Rectangle bounds. * @param paletteIndex - Palette color index. */ public drawRect(rect: Rect2i, paletteIndex: number): void { this.assertPaletteIndex(paletteIndex); this.renderer?.drawRect(rect, paletteIndex); } /** * Draws a filled rectangle. * * @param rect - Rectangle bounds. * @param paletteIndex - Palette color index. */ public drawRectFill(rect: Rect2i, paletteIndex: number): void { this.assertPaletteIndex(paletteIndex); this.renderer?.drawRectFill(rect, paletteIndex); } /** * Draws text using the built-in 6x14 system font. * * The system font stores foreground pixels as palette index 1. The * `paletteIndex` parameter is converted to a sprite pipeline palette * offset so that each foreground pixel maps to `palette[paletteIndex]`. * * @param pos - Text position (top-left corner). * @param paletteIndex - Palette color index for the text. * @param text - String to display. */ public drawSystemText(pos: Vector2i, paletteIndex: number, text: string): void { this.assertPaletteIndex(paletteIndex); // Palette index 0 is transparent - nothing to draw. if (paletteIndex === 0) { return; } if (this.systemFont) { // Offset math: font stores foreground as index 1. // Shader computes 1 + (paletteIndex - 1) = paletteIndex. this.renderer?.drawBitmapText(this.systemFont, pos, text, paletteIndex - 1); } } /** * Returns the built-in system font, or null if not yet initialized. * * @returns The system BitmapFont instance. */ public getSystemFont(): BitmapFont | null { return this.systemFont; } /** * Draws a sprite region from an indexed sprite sheet. * The renderer batches compatible sprite draws internally. * * @param spriteSheet - Source sprite sheet (must have been indexized via spriteSheet.indexize()). * @param srcRect - Region to copy from the sprite sheet. * @param destPos - Screen position to draw in (the top-left corner). * @param paletteOffset - Palette index offset applied at draw time (default 0). * @throws If the sprite sheet has not been indexized. */ public drawSprite(spriteSheet: SpriteSheet, srcRect: Rect2i, destPos: Vector2i, paletteOffset: number = 0): void { this.assertPaletteIndex(paletteOffset); this.requireIndexizedSheet(spriteSheet); this.renderer?.drawSprite(spriteSheet, srcRect, destPos, paletteOffset); } /** * Draws text using a bitmap font with variable-width glyphs. * Supports Unicode characters and per-glyph render offsets. * * @param font - Bitmap font containing character glyphs (underlying sheet must be indexized). * @param pos - Text position (top-left corner). * @param text - String to render. * @param paletteOffset - Palette index offset applied to all glyphs (default 0). * @throws If the font's sprite sheet has not been indexized. */ public drawBitmapText(font: BitmapFont, pos: Vector2i, text: string, paletteOffset: number = 0): void { this.assertPaletteIndex(paletteOffset); this.requireIndexizedSheet(font.getSpriteSheet()); this.renderer?.drawBitmapText(font, pos, text, paletteOffset); } // #endregion // #region Rendering API - Sprites /** * Re-indexizes all tracked sprite sheets against the current active palette. * * Call this after swapping or modifying the active palette to keep all loaded * sprite sheets in sync with the new color-to-index mapping. * * @throws If no active palette has been set. */ public spritesRefresh(): void { if (!this.palette) { throw new Error(noActivePaletteError()); } let refreshed = 0; for (const sheet of this.spriteSheets) { if (!sheet.isIndexized()) { this.spriteSheets.delete(sheet); continue; } try { sheet.reindexize(this.palette); refreshed++; } catch (e) { console.error('[BT] spritesRefresh: failed to reindexize sheet, removing from registry:', e); this.spriteSheets.delete(sheet); } } console.log(`[BT] Refreshed ${refreshed} sprite sheet(s) against current palette`); } // #endregion // #region Frame Capture API /** * Captures the next rendered frame as a PNG blob. * The capture occurs on the next completed render cycle. * * @returns Promise resolving to a PNG Blob. * @throws Error if the renderer is not initialized. */ public captureFrame(): Promise { if (!this.renderer) { return Promise.reject(new Error("Can't capture frame: renderer not initialized")); } return this.renderer.captureFrame(); } // #endregion // #region Camera API /** * Sets the camera offset for scrolling effects. * The offset is applied to subsequent renderer draw calls. * * @param offset - Camera position offset in pixels. */ public setCameraOffset(offset: Vector2i): void { this.renderer?.setCameraOffset(offset); } /** * Gets the current camera offset. * * @returns Current camera position offset. */ public getCameraOffset(): Vector2i { return this.renderer?.getCameraOffset() ?? Vector2i.zero(); } /** * Resets the camera offset to (0, 0). */ public resetCamera(): void { this.renderer?.resetCamera(); } // #endregion // #region Palette Effects API /** * Starts rotating a range of palette entries at a constant speed. * * Classic water/fire/plasma animation. Runs indefinitely until cancelled * via {@link paletteClearEffects}. * * @param start - First palette index in the cycling range (inclusive). * @param end - Last palette index in the cycling range (inclusive). * @param speed - Steps per second. Positive = forward, negative = backward. */ public paletteCycle(start: number, end: number, speed: number): void { if (!Number.isFinite(speed)) { throw new Error(`paletteCycle: 'speed' should be a number (got ${speed}).`); } if (!Number.isInteger(start) || !Number.isInteger(end) || start >= end) { throw new Error(`paletteCycle: start must be an integer less than end, got [${start}, ${end}].`); } this.paletteEffects.add(new CycleEffect(start, end, speed)); } /** * Smoothly interpolates all palette entries toward a target over time. * * Snapshots the current palette at start. Auto-removes when complete. * * @param target - Target palette to fade toward. * @param durationMs - Fade duration in milliseconds. * @param easing - Easing curve. Defaults to `'linear'`. */ public paletteFade(target: Palette, durationMs: number, easing?: EasingFunction): void { if (!this.palette) { throw new Error(noActivePaletteError()); } this.assertFiniteDuration('paletteFade', durationMs); this.paletteEffects.add(new FadeEffect(this.palette, target, durationMs, easing)); } /** * Fades only a subset of palette indices toward a target over time. * * @param start - First palette index to fade (inclusive). * @param end - Last palette index to fade (inclusive). * @param target - Target palette to fade toward. * @param durationMs - Fade duration in milliseconds. * @param easing - Easing curve. Defaults to `'linear'`. */ public paletteFadeRange( start: number, end: number, target: Palette, durationMs: number, easing?: EasingFunction, ): void { if (!this.palette) { throw new Error(noActivePaletteError()); } this.assertFiniteDuration('paletteFadeRange', durationMs); this.paletteEffects.add(new FadeRangeEffect(start, end, this.palette, target, durationMs, easing)); } /** * Temporarily sets all non-zero palette entries to a single color, then restores. * * Index 0 (transparent) is preserved. Auto-removes after duration. * * @param color - Flash color applied to all non-zero entries. * @param durationMs - How long the flash lasts in milliseconds. */ public paletteFlash(color: Color32, durationMs: number): void { if (!this.palette) { throw new Error(noActivePaletteError()); } this.assertFiniteDuration('paletteFlash', durationMs); this.paletteEffects.add(new FlashEffect(color, durationMs)); } /** * Instantly exchanges two palette entries. * * This is an immediate operation, not an animated effect. * * @param indexA - First palette index. * @param indexB - Second palette index. */ public paletteSwap(indexA: number, indexB: number): void { if (!this.palette) { throw new Error(noActivePaletteError()); } paletteSwap(this.palette, indexA, indexB); } /** * Cancels all running palette effects immediately. * * The palette stays at whatever state it was in when cancelled. */ public paletteClearEffects(): void { this.paletteEffects.clear(); } // #endregion // #region Post-Process Effects API /** * Appends a fullscreen post-processing effect to the chain. * * The first registered effect causes the scene to render into an offscreen * texture starting on the next frame. Effects run in registration order. * * @param effect - Effect instance to append. * @throws Error if the renderer has not been initialized. */ public effectAdd(effect: Effect): void { if (!this.renderer) { throw new Error('Cannot add effect: renderer not initialized.'); } this.renderer.addEffect(effect); } /** * Removes a previously registered post-processing effect. * * Calls the effect's optional dispose hook. Removing an effect that was * never added is a no-op. When the last effect is removed the renderer * reverts to drawing directly to the swap chain on the next frame. * * @param effect - Effect instance to remove. * @throws Error if the renderer has not been initialized. */ public effectRemove(effect: Effect): void { if (!this.renderer) { throw new Error('Cannot remove effect: renderer not initialized.'); } this.renderer.removeEffect(effect); } /** * Removes every registered post-processing effect. * * @throws Error if the renderer has not been initialized. */ public effectClear(): void { if (!this.renderer) { throw new Error('Cannot clear effects: renderer not initialized.'); } this.renderer.clearEffects(); } // #endregion // #region Private - Initialization Helpers /** * Constructs and initializes the renderer for the active hardware settings. * * Logs the selected backend name, creates the requested renderer backend, * calls {@link IRenderer.init}, and reports success or failure. * * @param canvas - Render target canvas. * @param hw - Active hardware settings. * @returns `true` when the renderer is ready; `false` on failure. */ private async initRenderer(canvas: HTMLCanvasElement, hw: HardwareSettings): Promise { applyCanvasLayoutStyles(canvas, { displaySize: hw.displaySize, maxCanvasDisplaySize: hw.maxCanvasDisplaySize ?? new Vector2i(DEFAULT_MAX_CANVAS_DISPLAY_SIZE.x, DEFAULT_MAX_CANVAS_DISPLAY_SIZE.y), ...(hw.canvasDisplaySize !== undefined ? { canvasDisplaySize: hw.canvasDisplaySize } : {}), }); const requestedBackend = hw.renderer ?? 'webgpu'; if (requestedBackend !== 'software') { // Try WebGPU. initWebGPU returns null when navigator.gpu is absent and // throws when the adapter or device cannot be created. Both cases fall // through to the software renderer below. let webGPUResult: Awaited> = null; try { webGPUResult = await initWebGPU(canvas, hw.displaySize, hw.canvasDisplaySize); } catch (error) { if (error instanceof RenderDimensionLimitError) { return false; } // Adapter/device unavailable; fall through to software. } if (webGPUResult) { this.device = webGPUResult.device; this.context = webGPUResult.context; console.log('[BT] Initializing renderer (backend: webgpu)'); this.renderer = new WebGpuRenderer( webGPUResult.device, webGPUResult.context, hw.displaySize, // Only forward an explicit outputSize when canvasDisplaySize was // provided; that is the signal that unlocks the display tier. hw.canvasDisplaySize !== undefined ? webGPUResult.drawingBufferSize : undefined, hw.outputUpscaleFilter ?? 'nearest', ); if (!(await this.renderer.init())) { console.error('[BT] Failed to initialize renderer'); return false; } this.activeBackend = 'webgpu'; this.ticker = null; console.log('[BT] Renderer initialized'); return true; } console.warn('[BT] WebGPU unavailable, falling back to software renderer'); } // Software renderer path: explicit selection or automatic fallback. this.device = null; this.context = null; console.log('[BT] Initializing renderer (backend: software)'); this.renderer = new SoftwareRenderer(canvas, hw.displaySize, hw.canvasDisplaySize); if (!(await this.renderer.init())) { console.error('[BT] Failed to initialize renderer'); return false; } this.activeBackend = 'software'; this.ticker = new SoftwareTicker(`${BTAPI.VERSION_MAJOR}.${BTAPI.VERSION_MINOR}.${BTAPI.VERSION_PATCH}`); console.log('[BT] Renderer initialized'); return true; } /** * Applies URL renderer override from `?renderer=...` when present. * * Supported values: * - `software` * * Unknown values are ignored so accidental query typos do not break startup. */ private applyRendererQueryOverride(): void { if (!this.hwSettings) { return; } const override = BTAPI.getRendererQueryOverride(); if (!override) { return; } this.hwSettings.renderer = override; console.info(`[BT] URL override selected renderer backend: ${override}`); } /** * Reads renderer override from the current URL query string. * * @returns Supported renderer backend override, or null when absent/invalid. */ private static getRendererQueryOverride(): RendererBackend | null { const search = typeof globalThis.location?.search === 'string' ? globalThis.location.search : typeof window !== 'undefined' ? window.location?.search : ''; if (!search) { return null; } try { const renderer = new URLSearchParams(search).get('renderer'); if (renderer === 'software') { return 'software'; } } catch (error) { console.warn('[BT] Failed to parse renderer query override:', error); } return null; } /** * Removes pointer, keyboard, and gamepad subsystems. * * Pointer/keyboard detach DOM listeners; gamepad detaches polling state and * clears subsystem references. */ private clearInputSubsystems(): void { this.pointer?.detach(); this.pointer = null; this.keyboard?.detach(); this.keyboard = null; this.gamepad?.detach(); this.gamepad = null; } /** * Runs the demo's async {@link IBlitTechDemo.init}. On throw or * `false`, clears input subsystems that were attached earlier in the init * sequence. * * @param demo - Active demo instance. * @returns `true` when the demo reports success. */ private async runDemoInit(demo: IBlitTechDemo): Promise { try { const ok = await demo.init(); if (!ok) { console.error('[BT] Demo initialization failed'); this.clearInputSubsystems(); return false; } return true; } catch (err) { console.error('[BT] Demo initialization threw', err); this.clearInputSubsystems(); return false; } } // #endregion // #region Private Helpers /** * Logs a dropped-frame event from the {@link GameLoop} as a console warning. * * One line per event. The {@link GameLoop} auto-calibrates its baseline * to the actual rAF cadence, so sustained slowness re-baselines instead * of generating sustained log spam, leaving only genuine missed-vsync * events to be reported here. * * @param event - Dropped-frame event. */ private handleFrameDrop(event: FrameDropEvent): void { console.warn( `[BT] Dropped ${event.droppedFrames} frame(s) ` + `(frame time ${event.deltaTime.toFixed(1)}ms, expected ${event.expectedInterval.toFixed(1)}ms)`, ); } /** * Validates that a sprite sheet has been indexized and registers it for refresh tracking. * * @param sheet - Sprite sheet to validate. * @throws If the sprite sheet has not been indexized. */ private requireIndexizedSheet(sheet: SpriteSheet): void { if (!sheet.isIndexized()) { throw new Error(spriteNotIndexizedError()); } this.spriteSheets.add(sheet); } /** * Validates that a duration is a finite, non-negative number. * * @param method - Calling method name for the error message. * @param durationMs - Duration to validate. * @throws Error if the duration is not finite or is negative. */ private assertFiniteDuration(method: string, durationMs: number): void { if (!Number.isFinite(durationMs) || durationMs < 0) { throw new Error(`${method}: the time should be a non-negative number of milliseconds (got ${durationMs}).`); } } /** * Validates that a palette index is a non-negative integer and, when a palette * is active, that the index is within its range. * * The non-integer/negative check always runs regardless of palette state. * The range check only runs when a palette has been set. * * @param index - Palette index to validate. * @throws Error if the index is not a non-negative integer. * @throws Error if a palette is active and the index is out of its range. */ private assertPaletteIndex(index: number): void { if (!Number.isInteger(index) || index < 0) { throw new Error(paletteIndexNegativeError(index)); } if (this.palette && index >= this.palette.size) { throw new Error(paletteIndexOutOfRangeError(index, this.palette.size)); } } // #endregion }