From 72047a4ff5e7dab59bbde81dda9d67cc6576489c Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Mon, 18 May 2026 21:40:55 +0200 Subject: [PATCH] feat(examples): use partial configure() in basics demo Return only targetFPS from configure(); engine merges the rest from defaultConfig. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Vaclav Vancura --- docs/api-core.md | 7 +- docs/post-process-effects.md | 15 ++-- src/BlitTech.ts | 9 ++- src/core/BTAPI.test.ts | 20 +++++ src/core/BTAPI.ts | 6 +- src/core/IBlitTechDemo.test.ts | 44 +++++++++- src/core/IBlitTechDemo.ts | 138 +++++++++++++++++++++++++++++++- src/utils/CanvasLayoutStyles.ts | 10 +-- 8 files changed, 227 insertions(+), 22 deletions(-) diff --git a/docs/api-core.md b/docs/api-core.md index 927a6c6..18be82c 100644 --- a/docs/api-core.md +++ b/docs/api-core.md @@ -60,7 +60,12 @@ BT.activeBackend; // 'webgpu' | 'software' | null `BT.init()` selects WebGPU or falls back to the Canvas 2D software renderer automatically. When not using `bootstrap()`, set `canvas.tabIndex = 0` and call `canvas.focus()` so keyboard events reach the canvas. -**`HardwareSettings`** (returned from `configure()`): +**`HardwareSettings`** (resolved after `configure()`; the hook may return a partial object): + +`configure()` may return only the fields you want to override. The engine merges them with `defaultConfig()` via +`mergeHardwareSettings()` (also exported). Omit `displaySize` to inherit the full default resolution and `640×480` +output buffer. Include `displaySize` when you want a custom logical size; optional fields you omit then stay unset (for +example no `canvasDisplaySize` means a 1:1 drawing buffer). | Field | Type | Default | Description | | ---------------------- | ------------------------ | ----------- | --------------------------------------------- | diff --git a/docs/post-process-effects.md b/docs/post-process-effects.md index 6c596de..6475851 100644 --- a/docs/post-process-effects.md +++ b/docs/post-process-effects.md @@ -148,13 +148,14 @@ interface HardwareSettings { } ``` -When `canvasDisplaySize` is omitted from your **returned** `HardwareSettings`, the WebGPU drawing buffer matches -`displaySize`. Palette resolve still runs (logical indices to RGBA at that size). The display tier remains unavailable -(adding a display effect throws) because no explicit output buffer was configured. - -If the demo **does not** implement `configure()`, the engine uses `defaultConfig()`, which **does** set -`canvasDisplaySize` (`640x480` for `320x240` logical), so the display tier remains available unless you override with a -custom `configure()` that omits output sizing. +When `canvasDisplaySize` is omitted from a `configure()` return value that **includes** `displaySize`, the WebGPU +drawing buffer matches `displaySize`. Palette resolve still runs (logical indices to RGBA at that size). The display +tier remains unavailable (adding a display effect throws) because no explicit output buffer was configured. + +If the demo **does not** implement `configure()`, or returns a partial object **without** `displaySize` (for example +only `{ targetFPS: 30 }`), the engine merges with `defaultConfig()`, which **does** set `canvasDisplaySize` (`640x480` +for `320x240` logical), so the display tier remains available unless you set a custom `displaySize` and omit output +sizing. --- diff --git a/src/BlitTech.ts b/src/BlitTech.ts index 2eef7dd..c3665bc 100644 --- a/src/BlitTech.ts +++ b/src/BlitTech.ts @@ -18,7 +18,13 @@ import { Palette } from './assets/Palette'; import type { IndexedSpriteLoadResult } from './assets/SpriteSheet'; import { SpriteSheet } from './assets/SpriteSheet'; import { BTAPI } from './core/BTAPI'; -import { defaultConfig, type HardwareSettings, type IBlitTechDemo, type RendererBackend } from './core/IBlitTechDemo'; +import { + defaultConfig, + type HardwareSettings, + type IBlitTechDemo, + mergeHardwareSettings, + type RendererBackend, +} from './core/IBlitTechDemo'; import { createDefaultKeyboardRuntimeMaps, DEFAULT_KEYBOARD_PLAYER1, @@ -1509,6 +1515,7 @@ export { getCanvas, green, Interference, + mergeHardwareSettings, Noise, Palette, PixelGlitch, diff --git a/src/core/BTAPI.test.ts b/src/core/BTAPI.test.ts index 13ad988..707e350 100644 --- a/src/core/BTAPI.test.ts +++ b/src/core/BTAPI.test.ts @@ -625,6 +625,26 @@ describe('BTAPI', () => { expect(BTAPI.instance.getGamepad()).not.toBeNull(); }); + it('should merge partial configure with defaultConfig', async () => { + const demo: IBlitTechDemo = { + configure: () => ({ targetFPS: 30 }), + init: vi.fn().mockResolvedValue(true), + update: vi.fn(), + render: vi.fn(), + }; + + const result = await BTAPI.instance.init(demo, makeMockCanvas()); + + expect(result).toBe(true); + + const hw = BTAPI.instance.getHardwareSettings(); + + expect(hw).not.toBeNull(); + expect(hw?.displaySize.x).toBe(320); + expect(hw?.canvasDisplaySize?.x).toBe(640); + expect(hw?.targetFPS).toBe(30); + }); + it('should use defaultConfig when configure is omitted', async () => { const demo: IBlitTechDemo = { init: vi.fn().mockResolvedValue(true), diff --git a/src/core/BTAPI.ts b/src/core/BTAPI.ts index 924b05c..a3b9f93 100644 --- a/src/core/BTAPI.ts +++ b/src/core/BTAPI.ts @@ -33,7 +33,7 @@ import { Vector2i } from '../utils/Vector2i'; import type { FrameDropCallback, FrameDropEvent } from './GameLoop'; import { GameLoop } from './GameLoop'; import type { HardwareSettings, IBlitTechDemo, RendererBackend } from './IBlitTechDemo'; -import { defaultConfig } from './IBlitTechDemo'; +import { defaultConfig, mergeHardwareSettings } from './IBlitTechDemo'; import { initWebGPU } from './WebGPUContext'; /** @@ -179,7 +179,7 @@ export class BTAPI { // Hardware settings: demo hook or defaults (320x240 @ 60 FPS). console.log('[BT] Reading hardware configuration'); - let configured: HardwareSettings | undefined; + let configured: Partial | undefined; try { configured = demo.configure?.(); @@ -191,7 +191,7 @@ export class BTAPI { return false; } - this.hwSettings = configured ?? defaultConfig(); + this.hwSettings = mergeHardwareSettings(configured); this.applyRendererQueryOverride(); const { targetFPS } = this.hwSettings; diff --git a/src/core/IBlitTechDemo.test.ts b/src/core/IBlitTechDemo.test.ts index a640ae3..e79eab4 100644 --- a/src/core/IBlitTechDemo.test.ts +++ b/src/core/IBlitTechDemo.test.ts @@ -9,7 +9,8 @@ import { describe, expect, it } from 'vitest'; -import { defaultConfig } from './IBlitTechDemo'; +import { Vector2i } from '../utils/Vector2i'; +import { defaultConfig, mergeHardwareSettings } from './IBlitTechDemo'; describe('defaultConfig', () => { it('should return 320x240 display size', () => { @@ -55,3 +56,44 @@ describe('defaultConfig', () => { expect(a.maxCanvasDisplaySize).not.toBe(b.maxCanvasDisplaySize); }); }); + +describe('mergeHardwareSettings', () => { + it('returns defaultConfig when partial is undefined', () => { + const settings = mergeHardwareSettings(); + + expect(settings.displaySize.x).toBe(320); + expect(settings.canvasDisplaySize?.x).toBe(640); + expect(settings.targetFPS).toBe(60); + }); + + it('merges targetFPS-only partials with full defaults', () => { + const settings = mergeHardwareSettings({ targetFPS: 30 }); + + expect(settings.displaySize.x).toBe(320); + expect(settings.canvasDisplaySize?.x).toBe(640); + expect(settings.targetFPS).toBe(30); + }); + + it('keeps canvasDisplaySize unset when displaySize is provided without output sizing', () => { + const settings = mergeHardwareSettings({ + displaySize: defaultConfig().displaySize, + targetFPS: 60, + }); + + expect(settings.displaySize.x).toBe(320); + expect(settings.canvasDisplaySize).toBeUndefined(); + expect(settings.targetFPS).toBe(60); + }); + + it('applies only provided fields when displaySize is set', () => { + const settings = mergeHardwareSettings({ + displaySize: new Vector2i(320, 240), + canvasDisplaySize: new Vector2i(640, 480), + renderer: 'software', + }); + + expect(settings.canvasDisplaySize?.x).toBe(640); + expect(settings.renderer).toBe('software'); + expect(settings.targetFPS).toBe(60); + }); +}); diff --git a/src/core/IBlitTechDemo.ts b/src/core/IBlitTechDemo.ts index c00ab60..921a83f 100644 --- a/src/core/IBlitTechDemo.ts +++ b/src/core/IBlitTechDemo.ts @@ -125,9 +125,16 @@ export interface IBlitTechDemo { * When omitted, the engine uses {@link defaultConfig} (`320x240` at * `60` FPS). * - * @returns Hardware configuration for this demo. + * When present, you may return only the fields you want to change; the + * engine merges them with {@link defaultConfig} via + * {@link mergeHardwareSettings}. Omit `displaySize` to inherit the full + * default resolution and output buffer. Include `displaySize` when you + * want a custom logical size; optional fields you omit then stay unset + * (for example no `canvasDisplaySize` means a 1:1 drawing buffer). + * + * @returns Partial hardware configuration for this demo. */ - configure?(): HardwareSettings; + configure?(): Partial; /** * Called once after the selected renderer backend has been initialized. @@ -184,4 +191,131 @@ export function defaultConfig(): HardwareSettings { }; } +/** + * Clones a {@link Vector2i} so merged settings do not share mutable references. + * + * @param size - Source vector. + * @returns Fresh vector with the same components. + */ +function cloneVector2i(size: Vector2i): Vector2i { + return new Vector2i(size.x, size.y); +} + +/** + * Copies only defined fields from a partial configure() return value. + * + * @param partial - Values returned by the demo's `configure()` hook. + * @returns Partial settings containing only defined entries, with vectors cloned. + */ +function pickDefinedHardwareSettings(partial: Partial): Partial { + const picked: Partial = {}; + + if (partial.displaySize !== undefined) { + picked.displaySize = cloneVector2i(partial.displaySize); + } + + if (partial.canvasDisplaySize !== undefined) { + picked.canvasDisplaySize = cloneVector2i(partial.canvasDisplaySize); + } + + if (partial.maxCanvasDisplaySize !== undefined) { + picked.maxCanvasDisplaySize = cloneVector2i(partial.maxCanvasDisplaySize); + } + + if (partial.targetFPS !== undefined) { + picked.targetFPS = partial.targetFPS; + } + + if (partial.outputUpscaleFilter !== undefined) { + picked.outputUpscaleFilter = partial.outputUpscaleFilter; + } + + if (partial.detectDroppedFrames !== undefined) { + picked.detectDroppedFrames = partial.detectDroppedFrames; + } + + if (partial.renderer !== undefined) { + picked.renderer = partial.renderer; + } + + return picked; +} + +/** + * Merges partial settings with {@link defaultConfig} when the demo did not set + * `displaySize` (for example only `{ targetFPS: 30 }`). + * + * @param picked - Defined fields from `configure()`. + * @param defaults - Baseline hardware settings. + * @returns Resolved settings with full default resolution and output buffer. + */ +function mergePartialWithFullDefaults(picked: Partial, defaults: HardwareSettings): HardwareSettings { + const canvasDisplaySize = + picked.canvasDisplaySize ?? + (defaults.canvasDisplaySize !== undefined ? cloneVector2i(defaults.canvasDisplaySize) : undefined); + const maxCanvasDisplaySize = + picked.maxCanvasDisplaySize ?? + (defaults.maxCanvasDisplaySize !== undefined ? cloneVector2i(defaults.maxCanvasDisplaySize) : undefined); + const outputUpscaleFilter = picked.outputUpscaleFilter ?? defaults.outputUpscaleFilter; + const detectDroppedFrames = picked.detectDroppedFrames ?? defaults.detectDroppedFrames; + const renderer = picked.renderer ?? defaults.renderer; + + return { + displaySize: cloneVector2i(defaults.displaySize), + targetFPS: picked.targetFPS ?? defaults.targetFPS, + ...(canvasDisplaySize !== undefined ? { canvasDisplaySize } : {}), + ...(maxCanvasDisplaySize !== undefined ? { maxCanvasDisplaySize } : {}), + ...(outputUpscaleFilter !== undefined ? { outputUpscaleFilter } : {}), + ...(detectDroppedFrames !== undefined ? { detectDroppedFrames } : {}), + ...(renderer !== undefined ? { renderer } : {}), + }; +} + +/** + * Applies only fields present in `configure()` when the demo set `displaySize`. + * + * @param picked - Defined fields with vectors cloned. + * @param defaults - Baseline hardware settings for required fallbacks. + * @returns Resolved settings; omitted optionals such as `canvasDisplaySize` stay unset. + */ +function mergeExplicitDisplayProfile(picked: Partial, defaults: HardwareSettings): HardwareSettings { + return { + displaySize: picked.displaySize ?? cloneVector2i(defaults.displaySize), + targetFPS: picked.targetFPS ?? defaults.targetFPS, + ...(picked.canvasDisplaySize !== undefined ? { canvasDisplaySize: picked.canvasDisplaySize } : {}), + ...(picked.maxCanvasDisplaySize !== undefined ? { maxCanvasDisplaySize: picked.maxCanvasDisplaySize } : {}), + ...(picked.outputUpscaleFilter !== undefined ? { outputUpscaleFilter: picked.outputUpscaleFilter } : {}), + ...(picked.detectDroppedFrames !== undefined ? { detectDroppedFrames: picked.detectDroppedFrames } : {}), + ...(picked.renderer !== undefined ? { renderer: picked.renderer } : {}), + }; +} + +/** + * Resolves demo `configure()` output into complete {@link HardwareSettings}. + * + * When `displaySize` is omitted from `partial`, unset fields inherit from + * {@link defaultConfig} (including `canvasDisplaySize`). When `displaySize` is + * provided, only fields present in `partial` are applied; omitted optionals such + * as `canvasDisplaySize` remain unset so the drawing buffer can match logical + * resolution. + * + * @param partial - Optional partial settings from `configure()`. + * @returns Resolved hardware settings for initialization. + */ +export function mergeHardwareSettings(partial?: Partial): HardwareSettings { + const defaults = defaultConfig(); + + if (partial === undefined) { + return defaults; + } + + const picked = pickDefinedHardwareSettings(partial); + + if (partial.displaySize === undefined) { + return mergePartialWithFullDefaults(picked, defaults); + } + + return mergeExplicitDisplayProfile(picked, defaults); +} + // #endregion diff --git a/src/utils/CanvasLayoutStyles.ts b/src/utils/CanvasLayoutStyles.ts index ed23339..6951c5a 100644 --- a/src/utils/CanvasLayoutStyles.ts +++ b/src/utils/CanvasLayoutStyles.ts @@ -63,13 +63,9 @@ export function applyCanvasLayoutStyles(canvas: HTMLCanvasElement, options: Canv canvas.style.setProperty('max-width', maxW, 'important'); canvas.style.setProperty('max-height', maxH, 'important'); - if (options.canvasDisplaySize) { - canvas.style.width = `${options.canvasDisplaySize.x}px`; - canvas.style.height = `${options.canvasDisplaySize.y}px`; - } else { - canvas.style.width = ''; - canvas.style.height = ''; - } + // Width/height are sized by layout.html min(100dvw, 100dvh, …); only CSS variables are needed here. + canvas.style.width = ''; + canvas.style.height = ''; } // #endregion -- 2.51.2