diff --git a/.claude/rules/bt-api-getters.md b/.claude/rules/bt-api-getters.md new file mode 100644 index 0000000..cdfaa23 --- /dev/null +++ b/.claude/rules/bt-api-getters.md @@ -0,0 +1,29 @@ +# BT API: getters vs methods + +Canonical reference: [CLAUDE.md](../../CLAUDE.md) (**BT API: getters vs methods**). + +Quick rules when changing `src/BlitTech.ts` or demos: + +- **Getter:** zero-arg read-only snapshot (`BT.displaySize`, `BT.targetFPS`, `BT.ticks`, `BT.activeBackend`) +- **Method:** mutation, parameters, or async (`BT.cameraSet`, `BT.pointerPos(0)`, `await BT.captureFrame()`) +- **Never** reintroduce `BT.displaySize()` / `BT.getActiveBackend()`-style call syntax for these reads + +## Getter list + +| Category | Members | +| --- | --- | +| Configure-time (mirror `HardwareSettings` names) | `displaySize`, `canvasDisplaySize`, `targetFPS`, `outputSize` | +| Loop timing | `deltaSeconds`, `timeSeconds`, `ticks` | +| Runtime state | `activeBackend`, `camera`, `palette` | +| Per-frame input | `pointerScrollDelta`, `inputString`, `gamepadCount` | + +`outputSize` = effective buffer (`canvasDisplaySize ?? displaySize`). `Vector2i` getters return a clone per read. +`activeBackend` is what actually started after fallback, not `configure().renderer`. +`palette` is a live reference — mutating slots affects rendering on the next frame. + +## Naming when adding getters + +- Match `HardwareSettings` field name exactly for configure values (`targetFPS`, not `fps` or `targetFps`) +- Use a runtime-descriptive name when no configure field exists (`activeBackend`, not `renderer`) + +Cursor: `.cursor/rules/bt-api-getters.mdc` (always applied in this repo). diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index ad5cb2d..4f3c51b 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -31,6 +31,9 @@ Review current changes against project rules and quality standards. - Type imports use `import type` syntax - Proper error handling (guard clauses, null checks) - Consistent naming conventions + - **BT API shape:** read-only zero-arg snapshots use getters (`BT.displaySize`, `BT.targetFPS`), not `BT.foo()`. + Actions and parameterized queries stay methods. New configure mirrors use `HardwareSettings` field names + (`targetFPS`, not `fps`). See `CLAUDE.md` (**BT API: getters vs methods**). 4. **Summarize findings** - List critical issues that must be fixed diff --git a/CLAUDE.md b/CLAUDE.md index c90db8a..38ec7c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ Before writing new code, reviewing existing code, or preflighting, check here fi | Question | Where to look | | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| What does `BT.X()` do? | `src/BlitTech.ts` JSDoc, then `docs/api-*.md` | +| What does `BT.X` do (getter vs method)? | `src/BlitTech.ts` JSDoc, `docs/api-core.md`, **BT API: getters vs methods** below | | How does a subsystem work internally? | The relevant `src/core/` or `src/render/` file | | What does a demo implement? | `src/core/IBlitTechDemo.ts` (interface + HardwareSettings) | | What palette/sprite setup pattern is correct? | `docs/palette-guide.md`, then `docs/api-assets.md` | @@ -108,7 +108,7 @@ Two backends selectable via `HardwareSettings.renderer` (default `'webgpu'`): blits, and bitmap text. Post-process/fullscreen effects throw a clear error directing users to the WebGPU backend. Activates automatically when WebGPU init fails; force explicitly via `HardwareSettings.renderer: 'software'` or the `?renderer=software` URL query parameter. A dismissible in-canvas ticker banner is rendered each frame when this - backend is active. Use `BT.getActiveBackend()` to query which backend started (`'webgpu' | 'software' | null`). + backend is active. Use `BT.activeBackend` to query which backend started (`'webgpu' | 'software' | null`). ### Core Types @@ -138,6 +138,40 @@ Two backends selectable via `HardwareSettings.renderer` (default `'webgpu'`): - Default gamepad stick dead zone is `0.75` - Triggers are axis-only for now (`AXIS_TRIGGER_L` / `AXIS_TRIGGER_R`); trigger button constants are tracked in `VV-481` +## BT API: getters vs methods + +The public `BT` namespace uses **getters** for read-only snapshots and **methods** for actions, parameterized queries, +and async work. Do not add new zero-argument `BT.foo()` functions when a getter is appropriate. + +### Use getters (property access, no `()`) + +| Category | Members | Notes | +| ---------------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| **Configure-time** (mirror {@link HardwareSettings} field names) | `displaySize`, `canvasDisplaySize`, `targetFPS`, `outputSize` | `outputSize` = effective buffer (`canvasDisplaySize ?? displaySize`). Clone per read for `Vector2i` getters. | +| **Loop timing** | `deltaSeconds`, `timeSeconds`, `ticks` | `targetFPS` is configured rate, not measured FPS. | +| **Runtime state** | `activeBackend`, `camera`, `palette` | `activeBackend` is what actually started (after fallback), not `configure().renderer`. `palette` is a live reference. | +| **Per-frame input** | `pointerScrollDelta`, `inputString`, `gamepadCount` | Read once per frame when needed. | + +Examples: `BT.displaySize.y`, `BT.targetFPS`, `BT.ticks % 60`, `if (BT.activeBackend === 'software')`. + +### Use methods (call with `()`) + +- **Lifecycle / mutations:** `init`, `ticksReset`, `cameraSet`, `cameraReset`, `paletteSet`, `hideCursor`, all + draw/clear/effect APIs. +- **Parameterized queries:** `pointerPos(index?)`, `pointerDelta`, `pointerPosValid`, `buttonDown` / `Pressed` / + `Released`, `getAxis`, `gamepadConnected`, `keyDown` / `Pressed` / `Released`. +- **Utilities with arguments:** `cameraClamp(camera, worldSize, viewSize?)`, `systemPrintMeasure(text)`. +- **Async:** `captureFrame`, `downloadFrame`. + +### Naming when adding getters + +- **Same name as `HardwareSettings`** when exposing configure values (`targetFPS`, not `fps` or `targetFps`). +- **Descriptive runtime names** when there is no configure field (`activeBackend`, not `renderer`). +- **Do not** expose `configure().renderer` on `BT` as `renderer` without documenting that it differs from + `activeBackend`. + +Full tables: `docs/api-core.md`. Style guide: `docs/developer-experience-guide.md` (Naming conventions). + ## API Conventions - Prefer `SpriteSheet.loadIndexed(...)` for demo/game sprite setup; use manual `loadColorsIntoPalette` + `load` + @@ -146,7 +180,7 @@ Two backends selectable via `HardwareSettings.renderer` (default `'webgpu'`): of the internal palette-indexed `Uint8Array` (throws if the sheet has not been indexized) - Prefer `Color32#luminance` for perceived brightness calculations instead of duplicating `0.299*r + 0.587*g + 0.114*b` at call sites -- Prefer fixed-step helpers `BT.deltaSeconds()` / `BT.timeSeconds()` over hardcoded `1 / TARGET_FPS` in update loops +- Prefer fixed-step helpers `BT.deltaSeconds` / `BT.timeSeconds` over hardcoded `1 / TARGET_FPS` in update loops - Prefer `BT.cameraClamp(...)` (or `clampCameraToWorld(...)` in utility code) over ad-hoc clamp math - Prefer `palette.applyHUD(startSlot?)` (default `1`) to fill the six common UI slots (white, bg, label, header, dim, FPS) and register their `hud_*` name aliases, rather than six manual `palette.set()` calls; override individual slots diff --git a/README.md b/README.md index a18d514..66dac37 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,8 @@ WebGPU support varies by browser: | Safari | 26+ | Enabled by default; Safari 18–25 available via Feature Flags | When WebGPU is unavailable the engine falls back to the Canvas 2D software renderer automatically. A dismissible -in-canvas "SOFTWARE RENDERER" banner appears to confirm the fallback is active. Use `BT.getActiveBackend()` to detect -which backend is running at runtime. +in-canvas "SOFTWARE RENDERER" banner appears to confirm the fallback is active. Use `BT.activeBackend` to detect which +backend is running at runtime. ## Contributors diff --git a/docs/api-core.md b/docs/api-core.md index 156a170..1d6a1e5 100644 --- a/docs/api-core.md +++ b/docs/api-core.md @@ -50,9 +50,11 @@ displayError('Init Failed', 'WebGPU unavailable.', 'my-container'); ```ts const ok = await BT.init(demo, canvas); // low-level init; prefer bootstrap() -BT.displaySize(); // Vector2i — configured logical resolution -BT.fps(); // number — target updates per second -BT.getActiveBackend(); // 'webgpu' | 'software' | null +BT.displaySize; // Vector2i — configured logical resolution (clone per read) +BT.canvasDisplaySize; // Vector2i | null — output buffer when set in configure() +BT.outputSize; // Vector2i — effective drawing-buffer size (clone per read) +BT.targetFPS; // number — target updates per second +BT.activeBackend; // 'webgpu' | 'software' | null ``` `BT.init()` selects WebGPU or falls back to the Canvas 2D software renderer automatically. When not using `bootstrap()`, @@ -69,14 +71,19 @@ set `canvas.tabIndex = 0` and call `canvas.focus()` so keyboard events reach the | `outputUpscaleFilter` | `'nearest' \| 'linear'` | `'nearest'` | Upscale filter | | `detectDroppedFrames` | `boolean` | `false` | Log a console warning on missed vsync | +**`BT` getters vs `configure()` fields:** `displaySize`, `canvasDisplaySize`, and `targetFPS` on `BT` mirror the same +names on {@link HardwareSettings}. `outputSize` is the effective drawing-buffer size +(`canvasDisplaySize ?? displaySize`). `activeBackend` is the backend that actually started (after fallback), not the +`renderer` value from `configure()`. + --- ## Game Loop Timing ```ts -BT.deltaSeconds(); // seconds per fixed tick (1 / BT.fps()) -BT.timeSeconds(); // elapsed seconds since init (ticks × deltaSeconds) -BT.ticks(); // current tick counter (increments each update) +BT.deltaSeconds; // seconds per fixed tick (1 / BT.targetFPS) +BT.timeSeconds; // elapsed seconds since init (ticks × deltaSeconds) +BT.ticks; // current tick counter (increments each update) BT.ticksReset(); // reset tick counter to 0 ``` @@ -99,7 +106,7 @@ spawn.remainingTicks(); // ticks until next fire spawn.intervalTicks; // readonly interval size ``` -`Timer.tick()` advances the internal baseline on each true return. Pass `BT.ticks()` explicitly only when you need a +`Timer.tick()` advances the internal baseline on each true return. Pass `BT.ticks` explicitly only when you need a specific snapshot; the default is the engine tick counter. --- @@ -110,12 +117,12 @@ The camera applies a global pixel offset to all subsequent draw calls. Integer o ```ts BT.cameraSet(new Vector2i(scrollX, scrollY)); // apply offset -BT.cameraGet(); // Vector2i — current offset +BT.camera; // Vector2i — current offset BT.cameraReset(); // set back to (0, 0) // Clamp a camera origin so the viewport stays within a world: const clamped = BT.cameraClamp(desired, worldSize); -// Optional third argument overrides the viewport size (default: BT.displaySize()): +// Optional third argument overrides the viewport size (default: BT.displaySize): const clamped = BT.cameraClamp(desired, worldSize, new Vector2i(160, 120)); ``` diff --git a/docs/api-palette.md b/docs/api-palette.md index 7b240e5..82fd82b 100644 --- a/docs/api-palette.md +++ b/docs/api-palette.md @@ -21,7 +21,7 @@ palette.getRef(1); // → Color32 (live reference — do not store) // Activate for rendering BT.paletteSet(palette); -BT.paletteGet(); // → active Palette; throws if none set +BT.palette; // → active Palette; throws if none set ``` --- diff --git a/docs/bitmap-fonts.md b/docs/bitmap-fonts.md index 058a24c..38f6894 100644 --- a/docs/bitmap-fonts.md +++ b/docs/bitmap-fonts.md @@ -288,7 +288,7 @@ for (const line of lines) { ```ts const text = 'Centered'; const textWidth = font.measureText(text); -const screenWidth = BT.displaySize().x; +const screenWidth = BT.displaySize.x; const x = Math.floor((screenWidth - textWidth) / 2); BT.printFont(font, new Vector2i(x, 10), text, Color32.white); diff --git a/docs/developer-experience-guide.md b/docs/developer-experience-guide.md index 104dc1e..2c3cf57 100644 --- a/docs/developer-experience-guide.md +++ b/docs/developer-experience-guide.md @@ -122,6 +122,12 @@ AI-assisted commits add a trailer: `Co-Authored-By: Claude 0) { /* scrolled down */ diff --git a/docs/performance-best-practices.md b/docs/performance-best-practices.md index c42f8f9..c981f41 100644 --- a/docs/performance-best-practices.md +++ b/docs/performance-best-practices.md @@ -219,9 +219,9 @@ Blit-Tech uses a fixed 60 FPS timestep by default. This provides: ```ts // Frame-based timer (recommended) -if (BT.ticks() - lastActionTick >= 60) { +if (BT.ticks - lastActionTick >= 60) { performAction(); - lastActionTick = BT.ticks(); + lastActionTick = BT.ticks; } // Alternative: Delta time (more complex) @@ -273,7 +273,7 @@ Don't guess what's slow - **measure it**. Use: - Chrome DevTools Performance tab - `console.time()` / `console.timeEnd()` -- FPS counter: `BT.fps()` +- FPS counter: `BT.targetFPS` ### 3. Allocating in Hot Paths diff --git a/docs/software-fallback-smoke-matrix.md b/docs/software-fallback-smoke-matrix.md index a622e4e..056d1b0 100644 --- a/docs/software-fallback-smoke-matrix.md +++ b/docs/software-fallback-smoke-matrix.md @@ -22,16 +22,16 @@ in `VV-491` to cover auto-fallback and the dismissible ticker banner. ## Matrix -| Scene | What to verify | Software expected result | Notes | -| --------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------- | -| Any page without `?renderer=software` | Auto-fallback when WebGPU is absent | Demo boots; `BT.getActiveBackend()` = `'software'`; ticker visible | No error page or hard stop | -| Any page with software active | Dismissible ticker banner at top of canvas | Banner centered, dismisses on click/tap; absent next frame | Height 15 px; palette indices 1/2 | -| `tests/visual/fixtures/primitives.html` | clear + clearRect + primitive rasterization | Matches expected primitive layout and colors | Check pixel edges are crisp | -| `tests/visual/fixtures/camera.html` | camera offset applied to all draw calls | Geometry is shifted consistently by camera offset | No partial drift between primitives | -| `tests/visual/fixtures/sprites.html` | indexed sprites + palette offsets + transparency | Sprite shapes/colors match expected output | Transparent pixels stay see-through | -| `tests/visual/fixtures/fonts.html` | system text + bitmap font rendering | Text positions and glyph colors are correct | No missing glyph blocks | -| `tests/visual/fixtures/mixed.html` | primitives + sprites + layering order | Same stacking as WebGPU for this fixture | Parity covered by Playwright snapshot | -| Frame capture via `BT.captureFrame()` | PNG export in software mode | Promise resolves with PNG blob | Repeat capture across multiple frames | +| Scene | What to verify | Software expected result | Notes | +| --------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------- | +| Any page without `?renderer=software` | Auto-fallback when WebGPU is absent | Demo boots; `BT.activeBackend` = `'software'`; ticker visible | No error page or hard stop | +| Any page with software active | Dismissible ticker banner at top of canvas | Banner centered, dismisses on click/tap; absent next frame | Height 15 px; palette indices 1/2 | +| `tests/visual/fixtures/primitives.html` | clear + clearRect + primitive rasterization | Matches expected primitive layout and colors | Check pixel edges are crisp | +| `tests/visual/fixtures/camera.html` | camera offset applied to all draw calls | Geometry is shifted consistently by camera offset | No partial drift between primitives | +| `tests/visual/fixtures/sprites.html` | indexed sprites + palette offsets + transparency | Sprite shapes/colors match expected output | Transparent pixels stay see-through | +| `tests/visual/fixtures/fonts.html` | system text + bitmap font rendering | Text positions and glyph colors are correct | No missing glyph blocks | +| `tests/visual/fixtures/mixed.html` | primitives + sprites + layering order | Same stacking as WebGPU for this fixture | Parity covered by Playwright snapshot | +| Frame capture via `BT.captureFrame()` | PNG export in software mode | Promise resolves with PNG blob | Repeat capture across multiple frames | ## Automated regression diff --git a/src/BlitTech.test.ts b/src/BlitTech.test.ts index c3092ba..8bbb13f 100644 --- a/src/BlitTech.test.ts +++ b/src/BlitTech.test.ts @@ -107,7 +107,7 @@ describe('BT.displaySize', () => { it('returns zero vector when hardware settings are not available', () => { vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue(null); - const size = BT.displaySize(); + const size = BT.displaySize; expect(size.x).toBe(0); expect(size.y).toBe(0); @@ -116,7 +116,7 @@ describe('BT.displaySize', () => { it('returns a clone of displaySize from hardware settings', () => { vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue(mockHardwareSettings(new Vector2i(640, 480))); - const size = BT.displaySize(); + const size = BT.displaySize; expect(size.x).toBe(640); expect(size.y).toBe(480); @@ -125,9 +125,79 @@ describe('BT.displaySize', () => { // #endregion -// #region BT.fps +// #region BT.canvasDisplaySize / BT.outputSize -describe('BT.fps', () => { +describe('BT.canvasDisplaySize', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns null when hardware settings are not available', () => { + vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue(null); + + expect(BT.canvasDisplaySize).toBeNull(); + }); + + it('returns null when canvasDisplaySize was not configured', () => { + vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue(mockHardwareSettings()); + + expect(BT.canvasDisplaySize).toBeNull(); + }); + + it('returns a clone when canvasDisplaySize is configured', () => { + vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue({ + ...mockHardwareSettings(), + canvasDisplaySize: new Vector2i(640, 480), + }); + + const size = BT.canvasDisplaySize; + + expect(size?.x).toBe(640); + expect(size?.y).toBe(480); + }); +}); + +describe('BT.outputSize', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns zero vector when hardware settings are not available', () => { + vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue(null); + + const size = BT.outputSize; + + expect(size.x).toBe(0); + expect(size.y).toBe(0); + }); + + it('matches displaySize when canvasDisplaySize is omitted', () => { + vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue(mockHardwareSettings(new Vector2i(320, 240))); + + const size = BT.outputSize; + + expect(size.x).toBe(320); + expect(size.y).toBe(240); + }); + + it('returns canvasDisplaySize when configured', () => { + vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue({ + ...mockHardwareSettings(new Vector2i(320, 240)), + canvasDisplaySize: new Vector2i(640, 480), + }); + + const size = BT.outputSize; + + expect(size.x).toBe(640); + expect(size.y).toBe(480); + }); +}); + +// #endregion + +// #region BT.targetFPS + +describe('BT.targetFPS', () => { beforeEach(() => { vi.restoreAllMocks(); }); @@ -135,7 +205,7 @@ describe('BT.fps', () => { it('returns 60 when hardware settings are not available', () => { vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue(null); - expect(BT.fps()).toBe(60); + expect(BT.targetFPS).toBe(60); }); it('returns targetFPS from hardware settings', () => { @@ -143,7 +213,7 @@ describe('BT.fps', () => { mockHardwareSettings(new Vector2i(320, 240), 30), ); - expect(BT.fps()).toBe(30); + expect(BT.targetFPS).toBe(30); }); }); @@ -159,7 +229,7 @@ describe('BT.deltaSeconds', () => { it('returns 1/60 when hardware settings are not available', () => { vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue(null); - expect(BT.deltaSeconds()).toBeCloseTo(1 / 60); + expect(BT.deltaSeconds).toBeCloseTo(1 / 60); }); it('returns reciprocal of targetFPS from hardware settings', () => { @@ -167,7 +237,7 @@ describe('BT.deltaSeconds', () => { mockHardwareSettings(new Vector2i(320, 240), 50), ); - expect(BT.deltaSeconds()).toBeCloseTo(0.02); + expect(BT.deltaSeconds).toBeCloseTo(0.02); }); it('falls back to 1/60 when targetFPS is non-positive', () => { @@ -175,7 +245,7 @@ describe('BT.deltaSeconds', () => { mockHardwareSettings(new Vector2i(320, 240), 0), ); - expect(BT.deltaSeconds()).toBeCloseTo(1 / 60); + expect(BT.deltaSeconds).toBeCloseTo(1 / 60); }); it('falls back to 1/60 when targetFPS is non-finite', () => { @@ -183,7 +253,7 @@ describe('BT.deltaSeconds', () => { mockHardwareSettings(new Vector2i(320, 240), Number.NaN), ); - expect(BT.deltaSeconds()).toBeCloseTo(1 / 60); + expect(BT.deltaSeconds).toBeCloseTo(1 / 60); }); }); @@ -198,7 +268,7 @@ describe('BT.timeSeconds', () => { mockHardwareSettings(new Vector2i(320, 240), 30), ); - expect(BT.timeSeconds()).toBeCloseTo(3); + expect(BT.timeSeconds).toBeCloseTo(3); }); it('uses fallback delta when targetFPS is non-positive, producing finite time', () => { @@ -207,8 +277,8 @@ describe('BT.timeSeconds', () => { mockHardwareSettings(new Vector2i(320, 240), 0), ); - const time = BT.timeSeconds(); - expect(BT.deltaSeconds()).toBeCloseTo(1 / 60); + const time = BT.timeSeconds; + expect(BT.deltaSeconds).toBeCloseTo(1 / 60); expect(Number.isFinite(time)).toBe(true); expect(time).toBeCloseTo(2); }); @@ -219,8 +289,8 @@ describe('BT.timeSeconds', () => { mockHardwareSettings(new Vector2i(320, 240), Number.POSITIVE_INFINITY), ); - const time = BT.timeSeconds(); - expect(BT.deltaSeconds()).toBeCloseTo(1 / 60); + const time = BT.timeSeconds; + expect(BT.deltaSeconds).toBeCloseTo(1 / 60); expect(Number.isFinite(time)).toBe(true); expect(time).toBeCloseTo(2); }); @@ -238,7 +308,7 @@ describe('BT.ticks', () => { it('delegates to BTAPI.instance.getTicks', () => { vi.spyOn(BTAPI.instance, 'getTicks').mockReturnValue(42); - expect(BT.ticks()).toBe(42); + expect(BT.ticks).toBe(42); }); }); @@ -258,7 +328,7 @@ describe('BT.ticksReset', () => { // #endregion -// #region BT.paletteCreate / BT.paletteSet / BT.paletteGet +// #region BT.paletteCreate / BT.paletteSet / BT.palette describe('BT.paletteCreate', () => { it('creates a palette with the requested size', () => { @@ -302,7 +372,7 @@ describe('BT.paletteSet', () => { }); }); -describe('BT.paletteGet', () => { +describe('BT.palette', () => { beforeEach(() => { vi.restoreAllMocks(); }); @@ -312,13 +382,13 @@ describe('BT.paletteGet', () => { vi.spyOn(BTAPI.instance, 'getPalette').mockReturnValue(palette); - expect(BT.paletteGet()).toBe(palette); + expect(BT.palette).toBe(palette); }); it('throws when no palette is set', () => { vi.spyOn(BTAPI.instance, 'getPalette').mockReturnValue(null); - expect(() => BT.paletteGet()).toThrow('No palette set yet. Call BT.paletteSet'); + expect(() => BT.palette).toThrow('No palette set yet. Call BT.paletteSet'); }); }); @@ -445,7 +515,7 @@ describe('BT.drawRectFill', () => { // #endregion -// #region BT.cameraSet / BT.cameraGet / BT.cameraClamp / BT.cameraReset +// #region BT.cameraSet / BT.camera / BT.cameraClamp / BT.cameraReset describe('BT.cameraSet', () => { beforeEach(() => { @@ -462,7 +532,7 @@ describe('BT.cameraSet', () => { }); }); -describe('BT.cameraGet', () => { +describe('BT.camera', () => { beforeEach(() => { vi.restoreAllMocks(); }); @@ -471,7 +541,7 @@ describe('BT.cameraGet', () => { const expected = new Vector2i(64, 32); vi.spyOn(BTAPI.instance, 'getCameraOffset').mockReturnValue(expected); - const result = BT.cameraGet(); + const result = BT.camera; expect(result).toBe(expected); }); @@ -874,14 +944,14 @@ describe('BT.pointerScrollDelta', () => { it('returns 0 when the engine is not initialized', () => { vi.spyOn(BTAPI.instance, 'getPointer').mockReturnValue(null); - expect(BT.pointerScrollDelta()).toBe(0); + expect(BT.pointerScrollDelta).toBe(0); }); it('delegates to the pointer subsystem', () => { const getScrollDelta = vi.fn().mockReturnValue(42); vi.spyOn(BTAPI.instance, 'getPointer').mockReturnValue({ getScrollDelta } as never); - expect(BT.pointerScrollDelta()).toBe(42); + expect(BT.pointerScrollDelta).toBe(42); }); }); @@ -939,7 +1009,7 @@ describe('BT gamepad constants and APIs', () => { expect(getAxis).toHaveBeenCalledWith(BT.AXIS_LEFT_X, 1); expect(BT.gamepadConnected(1)).toBe(true); expect(isConnected).toHaveBeenCalledWith(1); - expect(BT.gamepadCount()).toBe(2); + expect(BT.gamepadCount).toBe(2); expect(connectedCount).toHaveBeenCalledWith(); }); @@ -958,7 +1028,7 @@ describe('BT gamepad constants and APIs', () => { it('returns 0 from gamepadCount when the gamepad subsystem is not initialized', () => { vi.spyOn(BTAPI.instance, 'getGamepad').mockReturnValue(null); - expect(BT.gamepadCount()).toBe(0); + expect(BT.gamepadCount).toBe(0); }); }); @@ -1338,9 +1408,9 @@ describe('BT.effectAdd / BT.effectRemove / BT.effectClear', () => { // #endregion -// #region BT.getActiveBackend +// #region BT.activeBackend -describe('BT.getActiveBackend', () => { +describe('BT.activeBackend', () => { beforeEach(() => { vi.restoreAllMocks(); }); @@ -1348,7 +1418,7 @@ describe('BT.getActiveBackend', () => { it('delegates to BTAPI.instance.getActiveBackend', () => { const spy = vi.spyOn(BTAPI.instance, 'getActiveBackend').mockReturnValue('webgpu'); - const result = BT.getActiveBackend(); + const result = BT.activeBackend; expect(spy).toHaveBeenCalled(); expect(result).toBe('webgpu'); @@ -1357,13 +1427,13 @@ describe('BT.getActiveBackend', () => { it('returns null before initialization', () => { vi.spyOn(BTAPI.instance, 'getActiveBackend').mockReturnValue(null); - expect(BT.getActiveBackend()).toBeNull(); + expect(BT.activeBackend).toBeNull(); }); it('returns software when BTAPI reports software backend active', () => { vi.spyOn(BTAPI.instance, 'getActiveBackend').mockReturnValue('software'); - expect(BT.getActiveBackend()).toBe('software'); + expect(BT.activeBackend).toBe('software'); }); }); diff --git a/src/BlitTech.ts b/src/BlitTech.ts index e9a618f..de24f12 100644 --- a/src/BlitTech.ts +++ b/src/BlitTech.ts @@ -398,68 +398,97 @@ export const BT = { // #region Hardware Information /** - * Returns the active internal display resolution in pixels. + * Active internal display resolution in pixels. * * This is the logical render size configured by the demo, not the canvas - * element's CSS size. + * element's CSS size. Each read returns a clone. * * @returns Configured display size, or `Vector2i.zero()` before initialization. */ - displaySize: (): Vector2i => { + get displaySize(): Vector2i { const settings = BTAPI.instance.getHardwareSettings(); return settings ? settings.displaySize.clone() : Vector2i.zero(); }, /** - * Returns the fixed update rate. + * Configured output drawing-buffer size in pixels, when set in `configure()`. * - * `update()` runs at this target frequency, while rendering may occur at a - * different cadence. + * `null` when `canvasDisplaySize` was omitted (logical resolution only; no + * display-tier post-process). Each read returns a clone when non-null. + * + * @returns Configured output buffer size, or `null` when not set. + */ + get canvasDisplaySize(): Vector2i | null { + const settings = BTAPI.instance.getHardwareSettings(); + const size = settings?.canvasDisplaySize; + + return size ? size.clone() : null; + }, + + /** + * Effective drawing-buffer size in pixels (`canvasDisplaySize ?? displaySize`). + * + * Each read returns a clone. + * + * @returns Effective output buffer size, or `Vector2i.zero()` before initialization. + */ + get outputSize(): Vector2i { + const settings = BTAPI.instance.getHardwareSettings(); + + if (!settings) { + return Vector2i.zero(); + } + + return (settings.canvasDisplaySize ?? settings.displaySize).clone(); + }, + + /** + * Target fixed-update rate in frames per second. + * + * Mirrors {@link HardwareSettings.targetFPS} from `configure()`. + * `update()` runs at this frequency; rendering may occur at a different cadence. * * @returns Target updates per second, or `60` before initialization. */ - fps: (): number => { + get targetFPS(): number { const settings = BTAPI.instance.getHardwareSettings(); return settings ? settings.targetFPS : 60; }, /** - * Returns fixed-step seconds per update tick. + * Fixed-step seconds per update tick. * - * Equivalent to `1 / BT.fps()` when `BT.fps()` is finite and positive. - * Falls back to `1 / 60` when FPS is non-finite or non-positive. + * Equivalent to `1 / BT.targetFPS` when `BT.targetFPS` is finite and positive. + * Falls back to `1 / 60` when target FPS is non-finite or non-positive. * * @returns Seconds advanced by one fixed update tick. */ - deltaSeconds: (): number => { - const fps = BT.fps(); + get deltaSeconds(): number { + const fps = BT.targetFPS; const validatedFps = Number.isFinite(fps) && fps > 0 ? fps : 60; return 1 / validatedFps; }, /** - * Returns fixed-step elapsed time in seconds. + * Fixed-step elapsed time in seconds (`BT.ticks * BT.deltaSeconds`). * - * Equivalent to `BT.ticks() * BT.deltaSeconds()`. - * - * @returns Elapsed fixed-step time in seconds. + * @returns Elapsed fixed-step time in seconds since initialization. */ - timeSeconds: (): number => { - return BT.ticks() * BT.deltaSeconds(); + get timeSeconds(): number { + return BT.ticks * BT.deltaSeconds; }, /** - * Returns the current fixed-update tick counter. + * Current fixed-update tick counter. * - * The counter increments once per engine update and is typically used for - * frame-based timing. + * Increments once per engine update. Reset via {@link BT.ticksReset}. * - * @returns Tick count since initialization or the last {@link BT.ticksReset}. + * @returns Current tick count since initialization or last reset. */ - ticks: (): number => { + get ticks(): number { return BTAPI.instance.getTicks(); }, @@ -471,15 +500,15 @@ export const BT = { }, /** - * Returns the renderer backend that is currently active. + * Renderer backend that is currently active. * - * Call this after initialization to check which backend the engine started. - * Useful when you want to adjust demo behavior — for example, skipping - * post-process effects that only work under WebGPU. + * `'webgpu'` or `'software'` after successful init; `null` before init or on failure. + * Useful when adjusting demo behavior — for example, skipping post-process effects + * that only work under WebGPU. * * @returns `'webgpu'` or `'software'` after successful init; `null` before init or on failure. */ - getActiveBackend: (): RendererBackend | null => { + get activeBackend(): RendererBackend | null { return BTAPI.instance.getActiveBackend(); }, @@ -525,12 +554,14 @@ export const BT = { }, /** - * Gets the active engine palette. + * Active engine palette (live reference — not a copy). + * + * Mutating slots updates colors on the next frame without {@link BT.paletteSet}. * - * @returns Active palette. + * @returns The active palette instance. * @throws Error if no palette has been set. */ - paletteGet: (): Palette => { + get palette(): Palette { const palette = BTAPI.instance.getPalette(); if (!palette) { @@ -827,11 +858,11 @@ export const BT = { }, /** - * Returns the current global camera offset. + * Current global camera offset (clone; safe to mutate without affecting the engine). * * @returns Camera translation in display pixels. */ - cameraGet: (): Vector2i => { + get camera(): Vector2i { return BTAPI.instance.getCameraOffset(); }, @@ -839,7 +870,7 @@ export const BT = { * Clamps a camera origin so the viewport stays within world bounds. * * Uses integer clamping per axis: `[0, worldSize - viewSize]`. - * If `viewSize` is omitted, the active `BT.displaySize()` is used. + * If `viewSize` is omitted, the active {@link BT.displaySize} is used. * * @param camera - Desired camera origin in world coordinates. * @param worldSize - Full world size in pixels. @@ -847,7 +878,7 @@ export const BT = { * @returns Clamped camera origin. */ cameraClamp: (camera: Vector2i, worldSize: Vector2i, viewSize?: Vector2i): Vector2i => { - return clampCameraToWorld(camera, worldSize, viewSize ?? BT.displaySize()); + return clampCameraToWorld(camera, worldSize, viewSize ?? BT.displaySize); }, /** @@ -906,15 +937,14 @@ export const BT = { }, /** - * Returns the wheel scroll delta accumulated during the current frame, in pixels. + * Wheel scroll delta accumulated during the current frame, in pixels. * * Aggregates `WheelEvent.deltaY` across all wheel events received since * the last frame, normalizing line and page delta modes to pixels. - * Returns `0` when the engine is not initialized. * - * @returns Vertical scroll delta in pixels for the current frame. + * @returns Vertical scroll delta in pixels for the current frame, or `0` when not initialized. */ - pointerScrollDelta: (): number => { + get pointerScrollDelta(): number { return BTAPI.instance.getPointer()?.getScrollDelta() ?? 0; }, @@ -1200,11 +1230,11 @@ export const BT = { }, /** - * Returns the number of currently connected gamepads (max 4). + * Number of currently connected gamepads (max 4). * - * @returns Connected gamepad count. + * @returns Connected gamepad count (0..4). */ - gamepadCount: (): number => { + get gamepadCount(): number { return BTAPI.instance.getGamepad()?.connectedCount() ?? 0; }, @@ -1256,9 +1286,9 @@ export const BT = { * (and Tab / Escape where `beforeinput` is unreliable). Read during `update()` / * `render()`; the buffer clears after each frame. * - * @returns Characters for text-entry helpers (VV-396). + * @returns Characters typed since the last frame flush. */ - inputString: (): string => { + get inputString(): string { return BTAPI.instance.getKeyboard()?.getInputString() ?? ''; }, diff --git a/src/input/KeyboardInput.ts b/src/input/KeyboardInput.ts index 4805e03..da03db9 100644 --- a/src/input/KeyboardInput.ts +++ b/src/input/KeyboardInput.ts @@ -8,7 +8,7 @@ /** Options supplied when attaching to the canvas. */ export interface KeyboardAttachOptions { /** - * Returns the current fixed-update tick (same as `BT.ticks()`). + * Returns the current fixed-update tick (same as `BT.ticks`). * Used when recording first key-down time for `keyPressed(..., repeatRate)`. */ getTicks: () => number; @@ -105,7 +105,7 @@ export class KeyboardInput { * Snapshots held keys into `prevHeld` for next frame's edge detection. * Clears the text buffer after the frame (call after demo has read {@link getInputString}). * - * @param _currentTick - Fixed-update tick count after this frame's updates (`BT.ticks()`). + * @param _currentTick - Fixed-update tick count after this frame's updates (`BT.ticks`). */ public endFrame(_currentTick: number): void { this.prevHeld.clear(); @@ -132,7 +132,7 @@ export class KeyboardInput { * * @param code - DOM key code string. * @param repeatRate - Ticks between repeats; omit or non-positive for edge only. - * @param currentTick - Current fixed-update tick (`BT.ticks()`). + * @param currentTick - Current fixed-update tick (`BT.ticks`). * @returns `true` on the initial press edge or on repeat ticks when configured. */ public isKeyPressed(code: string, repeatRate: number | undefined, currentTick: number): boolean { @@ -205,7 +205,7 @@ export class KeyboardInput { * * @param codes - `KeyboardEvent.code` values for one logical button. * @param repeatRate - Optional tick repeat interval for held buttons. - * @param currentTick - Current fixed-update tick (`BT.ticks()`). + * @param currentTick - Current fixed-update tick (`BT.ticks`). * @returns `true` on the initial edge or on repeat ticks when configured. */ public isButtonPressed(codes: readonly string[], repeatRate: number | undefined, currentTick: number): boolean { diff --git a/src/render/effects/display/Interference.ts b/src/render/effects/display/Interference.ts index 6829b97..7c0527e 100644 --- a/src/render/effects/display/Interference.ts +++ b/src/render/effects/display/Interference.ts @@ -9,7 +9,7 @@ import { FullscreenEffect } from '../FullscreenEffect'; * frame, producing a buzzing-noise feel. * * Display-tier. Drives jitter from {@link time}; demos typically pass - * `BT.ticks() / BT.fps()`. + * `BT.ticks / BT.targetFPS`. */ export class Interference extends FullscreenEffect { public readonly tier = 'display' as const; diff --git a/src/render/effects/display/RollLine.ts b/src/render/effects/display/RollLine.ts index a85b7a9..afb9506 100644 --- a/src/render/effects/display/RollLine.ts +++ b/src/render/effects/display/RollLine.ts @@ -6,7 +6,7 @@ import { FullscreenEffect } from '../FullscreenEffect'; * stripe of the image. Combination of three cosines + smoothstep gives the * stripe a soft top/bottom edge. * - * Display-tier. Demo drives {@link time} (typically `BT.ticks() / BT.fps()`). + * Display-tier. Demo drives {@link time} (typically `BT.ticks / BT.targetFPS`). */ export class RollLine extends FullscreenEffect { public readonly tier = 'display' as const; -- 2.51.2 From 8318da81c8dc0bdcdc9d5314ac9b5af4da4711f4 Mon Sep 17 00:00:00 2001 From: Vaclav Vancura Date: Sat, 16 May 2026 21:46:03 +0200 Subject: [PATCH 2/2] test(api): add clone-per-read contract tests for BT snapshot getters Verify that BT.canvasDisplaySize, BT.outputSize, and BT.camera each return an independent clone on every read, so mutating the returned vector does not affect subsequent reads. Also fixes a JSDoc {@link HardwareSettings} reference in docs/api-core.md that rendered as a literal tag in Markdown; replaced with a plain backtick reference. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Vaclav Vancura --- docs/api-core.md | 5 ++--- src/BlitTech.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/api-core.md b/docs/api-core.md index 1d6a1e5..2b8a5a8 100644 --- a/docs/api-core.md +++ b/docs/api-core.md @@ -72,9 +72,8 @@ set `canvas.tabIndex = 0` and call `canvas.focus()` so keyboard events reach the | `detectDroppedFrames` | `boolean` | `false` | Log a console warning on missed vsync | **`BT` getters vs `configure()` fields:** `displaySize`, `canvasDisplaySize`, and `targetFPS` on `BT` mirror the same -names on {@link HardwareSettings}. `outputSize` is the effective drawing-buffer size -(`canvasDisplaySize ?? displaySize`). `activeBackend` is the backend that actually started (after fallback), not the -`renderer` value from `configure()`. +names on `HardwareSettings`. `outputSize` is the effective drawing-buffer size (`canvasDisplaySize ?? displaySize`). +`activeBackend` is the backend that actually started (after fallback), not the `renderer` value from `configure()`. --- diff --git a/src/BlitTech.test.ts b/src/BlitTech.test.ts index 8bbb13f..ed6442c 100644 --- a/src/BlitTech.test.ts +++ b/src/BlitTech.test.ts @@ -155,6 +155,18 @@ describe('BT.canvasDisplaySize', () => { expect(size?.x).toBe(640); expect(size?.y).toBe(480); }); + + it('returns an independent clone per read', () => { + vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue({ + ...mockHardwareSettings(), + canvasDisplaySize: new Vector2i(640, 480), + }); + + const first = BT.canvasDisplaySize; + first!.x = 999; + + expect(BT.canvasDisplaySize?.x).toBe(640); + }); }); describe('BT.outputSize', () => { @@ -191,6 +203,15 @@ describe('BT.outputSize', () => { expect(size.x).toBe(640); expect(size.y).toBe(480); }); + + it('returns an independent clone per read', () => { + vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue(mockHardwareSettings(new Vector2i(320, 240))); + + const first = BT.outputSize; + first.x = 999; + + expect(BT.outputSize.x).toBe(320); + }); }); // #endregion @@ -545,6 +566,16 @@ describe('BT.camera', () => { expect(result).toBe(expected); }); + + it('returns an independent clone per read', () => { + const stored = new Vector2i(64, 32); + vi.spyOn(BTAPI.instance, 'getCameraOffset').mockImplementation(() => stored.clone()); + + const first = BT.camera; + first.x = 999; + + expect(BT.camera.x).toBe(64); + }); }); describe('BT.cameraClamp', () => {