diff --git a/CLAUDE.md b/CLAUDE.md index 788c521..2ff4411 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ src/ presets/ # Pre-configured stacks (crtPipBoy, amber, green) assets/ AssetLoader.ts # Image loading with caching - SpriteSheet.ts # GPU texture wrapper + SpriteSheet.ts # GPU texture wrapper (+ loadIndexed convenience path) BitmapFont.ts # Bitmap font system (.btfont) Palette.ts # 256-entry indexed color palette PaletteEffect.ts # Palette effect system (cycle, fade, flash, swap) @@ -54,6 +54,7 @@ src/ utils/ Bootstrap.ts # Demo bootstrap utilities BootstrapHelpers.ts # WebGPU detection, canvas lookup, error display + CameraUtils.ts # Camera clamp helper (world/view bounds) Vector2i.ts # Integer 2D vector Rect2i.ts # Integer rectangle Color32.ts # 32-bit RGBA color @@ -96,6 +97,13 @@ Dual WebGPU pipeline architecture: - 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` +## API Conventions + +- Prefer `SpriteSheet.loadIndexed(...)` for demo/game sprite setup; use manual `loadColorsIntoPalette` + `load` + + `indexize` only for advanced flows +- 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 + ## Code Style - 4-space indent, 120-char line width diff --git a/README.md b/README.md index 2b2c38d..e79f911 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ primitives, and fonts. - **Sprite system**: sprite sheets, palette-indexed textures, palette offset for color variations, automatic texture batching - **Bitmap fonts**: variable-width font rendering with palette offset support -- **Camera system**: scrolling with offset and reset +- **Camera system**: scrolling with offset/reset plus world clamping helpers (`BT.cameraClamp`, `clampCameraToWorld`) - **Asset loading**: sprite sheets and bitmap fonts from images with automatic caching - **Pointer input**: mouse, touch, and pen unified under four pointer slots (`BT.pointerPos`, `BT.pointerDelta`, `BT.buttonDown` with `BTN_POINTER_A..D`); scroll delta, cursor hide/show, display-space coordinates @@ -42,7 +42,8 @@ primitives, and fonts. default maps, remapping via `BT.inputMap` / `BT.inputMapReset`, and text accumulation via `BT.inputString` - **Gamepad input**: up to four players via standard Gamepad API (`BT.gamepadConnected`, `BT.gamepadCount`, `BT.getAxis`), with stick dead zone and face-button support through `BT.button*` -- **Fixed timestep**: deterministic 60 FPS loop with tick counter and optional dropped-frame detection +- **Fixed timestep**: deterministic update loop with tick counter and timing helpers (`BT.deltaSeconds`, + `BT.timeSeconds`) - **Clean API**: all engine access through the `BT` namespace - **Display scaling**: `canvasDisplaySize` drives the WebGPU drawing buffer and CSS size for crisp pixel art; engine `defaultConfig()` uses a `640x480` output for `320x240` logical (2x nearest) when a demo omits `configure()` @@ -173,8 +174,10 @@ class MyDemo implements IBlitTechDemo { BT.paletteSet(palette); // Load assets here (sprites, fonts, etc.) - // Example: const spriteSheet = await SpriteSheet.load('assets/sprites.png'); - // After loading: spriteSheet.indexize(palette); + // Preferred sprite setup path: + // const indexed = await SpriteSheet.loadIndexed('assets/sprites.png', palette, 10); + // const spriteSheet = indexed.sheet; + // const spriteRect = indexed.srcRect; return true; } @@ -308,12 +311,14 @@ getCanvas(canvasId?); // Get canvas element safely BT.init(demo, canvas); // Start the engine (low-level) BT.displaySize(); // Get display resolution BT.fps(); // Get target FPS +BT.deltaSeconds(); // Fixed-step seconds per update +BT.timeSeconds(); // Fixed-step elapsed seconds BT.ticks(); // Get current tick count BT.ticksReset(); // Reset tick counter ``` A palette must be set via `BT.paletteSet()` before any draw calls are made. The recommended place is `init()` in the -demo, before loading any sprite sheets. +demo, after sprite setup and before first render. ### Palette @@ -442,6 +447,11 @@ BT.drawRectFill(rect, paletteIndex); // Draw filled rectangle // Load sprite sheet from image (automatically cached) const spriteSheet = await SpriteSheet.load('path/to/sprites.png'); +// Preferred one-call palette-indexed setup path +const indexed = await SpriteSheet.loadIndexed('path/to/sprites.png', palette, 10); +BT.paletteSet(palette); +BT.drawSprite(indexed.sheet, indexed.srcRect, new Vector2i(20, 20)); + // Load bitmap font from .btfont file (automatically cached) const font = await BitmapFont.load('fonts/MyFont.btfont'); @@ -456,14 +466,15 @@ if (AssetLoader.isLoaded('path/to/sprites.png')) { ### Sprites and Text -Sprites use a palette-first rendering model. Every sprite sheet must be converted to palette indices before drawing: +Sprites use a palette-first rendering model. Recommended setup uses `SpriteSheet.loadIndexed(...)`: ```ts -// Convert RGBA pixels to palette indices (call once after paletteSet). -spriteSheet.indexize(palette); +const palette = BT.paletteCreate(256); +const indexed = await SpriteSheet.loadIndexed('sprites/hero.png', palette, 10); +BT.paletteSet(palette); -BT.drawSprite(sheet, srcRect, destPos); // Draw with original palette colors -BT.drawSprite(sheet, srcRect, destPos, 16); // Draw with paletteOffset=16 (color variation) +BT.drawSprite(indexed.sheet, indexed.srcRect, destPos); // Draw with original palette colors +BT.drawSprite(indexed.sheet, indexed.srcRect, destPos, 16); // Draw with paletteOffset=16 (color variation) BT.printFont(font, pos, text); // Draw text using bitmap font BT.printFont(font, pos, text, 8); // Draw text with paletteOffset=8 BT.systemPrint(pos, paletteIndex, text); // Draw text with the built-in 6x14 system font @@ -471,6 +482,14 @@ BT.systemPrintMeasure(text); // Measure system font text dimensions BT.spritesRefresh(); // Re-index all loaded sheets after palette swap ``` +Low-level setup remains available when needed: + +```ts +await SpriteSheet.loadColorsIntoPalette('sprites/hero.png', palette, 10); +const sheet = await SpriteSheet.load('sprites/hero.png'); +sheet.indexize(palette); +``` + **Palette offset:** The `paletteOffset` parameter shifts which palette range a sprite samples from at draw time. Useful for team colors, damage flashes, or palette-swap effects without duplicate assets. @@ -486,9 +505,12 @@ implemented in `drawSprite()`. They are planned for a future release. ```ts BT.cameraSet(offset); // Set camera offset BT.cameraGet(); // Get current offset +BT.cameraClamp(camera, worldSize, viewSize?); // Clamp camera origin to world bounds BT.cameraReset(); // Reset to (0, 0) ``` +`viewSize` defaults to `BT.displaySize()` when omitted. + ### Core Types ```ts @@ -513,6 +535,7 @@ Color32.transparent(); // Assets SpriteSheet.load(url); // Load sprite sheet (static method) +SpriteSheet.loadIndexed(url, palette, startSlot, options?); // Register colors + load + indexize BitmapFont.load(url); // Load bitmap font (static method) ``` diff --git a/docs/testing.md b/docs/testing.md index 087b94d..20d01eb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -24,7 +24,8 @@ and `vi` for browser API stubs. Tests that need a full DOM (Bootstrap, Bootstrap `// @vitest-environment happy-dom` directive. - **AssetLoader** - image caching and deduplication (Node + vi stubs) -- **SpriteSheet** - UV calculation, lazy texture creation (Node + GPU mocks) +- **SpriteSheet** - UV calculation, lazy texture creation, indexization, and `loadIndexed()` convenience flow (Node + + GPU mocks) - **BitmapFont** - glyph lookup, text measurement (Node + vi stubs) - **BootstrapHelpers** - WebGPU support detection, canvas lookup (happy-dom) - **Bootstrap** - full bootstrap lifecycle (happy-dom) diff --git a/src/BlitTech.test.ts b/src/BlitTech.test.ts index f105998..5779779 100644 --- a/src/BlitTech.test.ts +++ b/src/BlitTech.test.ts @@ -118,6 +118,45 @@ describe('BT.fps', () => { // #endregion +// #region BT.deltaSeconds / BT.timeSeconds + +describe('BT.deltaSeconds', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns 1/60 when hardware settings are not available', () => { + vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue(null); + + expect(BT.deltaSeconds()).toBeCloseTo(1 / 60); + }); + + it('returns reciprocal of targetFPS from hardware settings', () => { + vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue( + mockHardwareSettings(new Vector2i(320, 240), 50), + ); + + expect(BT.deltaSeconds()).toBeCloseTo(0.02); + }); +}); + +describe('BT.timeSeconds', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns ticks multiplied by deltaSeconds', () => { + vi.spyOn(BTAPI.instance, 'getTicks').mockReturnValue(90); + vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue( + mockHardwareSettings(new Vector2i(320, 240), 30), + ); + + expect(BT.timeSeconds()).toBeCloseTo(3); + }); +}); + +// #endregion + // #region BT.ticks / BT.ticksReset describe('BT.ticks', () => { @@ -294,7 +333,7 @@ describe('BT.drawRectFill', () => { // #endregion -// #region BT.cameraSet / BT.cameraGet / BT.cameraReset +// #region BT.cameraSet / BT.cameraGet / BT.cameraClamp / BT.cameraReset describe('BT.cameraSet', () => { beforeEach(() => { @@ -326,6 +365,28 @@ describe('BT.cameraGet', () => { }); }); +describe('BT.cameraClamp', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('clamps camera coordinates when viewSize is provided', () => { + const clamped = BT.cameraClamp(new Vector2i(500, 300), new Vector2i(640, 480), new Vector2i(320, 240)); + + expect(clamped.equalsXY(320, 240)).toBe(true); + }); + + it('uses BT.displaySize when viewSize is omitted', () => { + vi.spyOn(BTAPI.instance, 'getHardwareSettings').mockReturnValue( + mockHardwareSettings(new Vector2i(200, 150), 60), + ); + + const clamped = BT.cameraClamp(new Vector2i(100, 100), new Vector2i(250, 200)); + + expect(clamped.equalsXY(50, 50)).toBe(true); + }); +}); + describe('BT.cameraReset', () => { beforeEach(() => { vi.restoreAllMocks(); diff --git a/src/BlitTech.ts b/src/BlitTech.ts index 2e263f9..989cc7d 100644 --- a/src/BlitTech.ts +++ b/src/BlitTech.ts @@ -6,14 +6,16 @@ * * Rendering is palette-first: every color on screen is identified by a numeric * palette index rather than a direct RGBA value. Set an active palette with - * `BT.paletteSet()` before drawing anything, and call `spriteSheet.indexize(palette)` - * after loading each sprite sheet to convert its RGBA pixels to palette indices. + * `BT.paletteSet()` before drawing anything. For sprite setup, prefer + * `SpriteSheet.loadIndexed(...)` as the one-call path (load colors, load image, + * indexize); low-level `spriteSheet.indexize(palette)` remains available. */ import { AssetLoader } from './assets/AssetLoader'; import type { TextSize } from './assets/BitmapFont'; import { BitmapFont } from './assets/BitmapFont'; 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 } from './core/IBlitTechDemo'; @@ -41,6 +43,7 @@ import { amber, crtPipBoy, green } from './render/effects/presets'; import type { BootstrapOptions } from './utils/Bootstrap'; import { bootstrap } from './utils/Bootstrap'; import { checkWebGPUSupport, displayError, getCanvas, previewWebGPUErrors } from './utils/BootstrapHelpers'; +import { clampCameraToWorld } from './utils/CameraUtils'; import { Color32 } from './utils/Color32'; import type { EasingFunction } from './utils/Easing'; import { applyEasing } from './utils/Easing'; @@ -314,6 +317,28 @@ export const BT = { return settings ? settings.targetFPS : 60; }, + /** + * Returns fixed-step seconds per update tick. + * + * Equivalent to `1 / BT.fps()`. + * + * @returns Seconds advanced by one fixed update tick. + */ + deltaSeconds: (): number => { + return 1 / BT.fps(); + }, + + /** + * Returns fixed-step elapsed time in seconds. + * + * Equivalent to `BT.ticks() * BT.deltaSeconds()`. + * + * @returns Elapsed fixed-step time in seconds. + */ + timeSeconds: (): number => { + return BT.ticks() * BT.deltaSeconds(); + }, + /** * Returns the current fixed-update tick counter. * @@ -634,6 +659,21 @@ export const BT = { return BTAPI.instance.getCameraOffset(); }, + /** + * 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. + * + * @param camera - Desired camera origin in world coordinates. + * @param worldSize - Full world size in pixels. + * @param viewSize - Viewport size in pixels (defaults to display size). + * @returns Clamped camera origin. + */ + cameraClamp: (camera: Vector2i, worldSize: Vector2i, viewSize?: Vector2i): Vector2i => { + return clampCameraToWorld(camera, worldSize, viewSize ?? BT.displaySize()); + }, + /** * Resets the global camera offset to `(0, 0)`. */ @@ -1157,7 +1197,8 @@ export const BT = { * {@link SpriteSheet} minimizes batch flushes and reduces GPU state changes. * * The sprite sheet must have been converted to palette indices via - * `spriteSheet.indexize(palette)` before the first draw call. + * `spriteSheet.indexize(palette)` before the first draw call. Prefer + * `SpriteSheet.loadIndexed(...)` for one-call setup. * * **Palette offset semantics:** Sprite pixels are stored as palette indices starting at 1. * Index 0 is always transparent and is discarded by the fragment shader. The final palette @@ -1236,6 +1277,7 @@ export { bootstrap, checkWebGPUSupport, ChromaticAberration, + clampCameraToWorld, Color32, crtPipBoy, defaultConfig, @@ -1258,5 +1300,6 @@ export { Vignette, }; export type { BootstrapOptions, EasingFunction, Effect, EffectTier, HardwareSettings, IBlitTechDemo, TextSize }; +export type { IndexedSpriteLoadResult }; // #endregion diff --git a/src/assets/SpriteSheet.test.ts b/src/assets/SpriteSheet.test.ts index 99049a3..53b8c3b 100644 --- a/src/assets/SpriteSheet.test.ts +++ b/src/assets/SpriteSheet.test.ts @@ -345,6 +345,48 @@ describe('SpriteSheet', () => { // #endregion + // #region loadIndexed + + describe('loadIndexed', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('loads colors, loads sheet, and indexizes in sequence', async () => { + const palette = new Palette(32); + const colors = [new Color32(1, 2, 3, 255)]; + const sheet = new SpriteSheet({ width: 12, height: 8 } as HTMLImageElement); + const indexizeSpy = vi.spyOn(sheet, 'indexize').mockReturnValue(undefined); + + const loadColorsSpy = vi.spyOn(SpriteSheet, 'loadColorsIntoPalette').mockResolvedValue(colors); + const loadSpy = vi.spyOn(SpriteSheet, 'load').mockResolvedValue(sheet); + + const result = await SpriteSheet.loadIndexed('hero.png', palette, 4); + + expect(loadColorsSpy).toHaveBeenCalledWith('hero.png', palette, 4, undefined); + expect(loadSpy).toHaveBeenCalledWith('hero.png'); + expect(indexizeSpy).toHaveBeenCalledWith(palette); + expect(result.sheet).toBe(sheet); + expect(result.colors).toBe(colors); + expect(result.srcRect.equals(new Rect2i(0, 0, 12, 8))).toBe(true); + }); + + it('forwards sort option to color registration', async () => { + const palette = new Palette(32); + const sheet = new SpriteSheet({ width: 2, height: 3 } as HTMLImageElement); + + const loadColorsSpy = vi.spyOn(SpriteSheet, 'loadColorsIntoPalette').mockResolvedValue([]); + vi.spyOn(SpriteSheet, 'load').mockResolvedValue(sheet); + vi.spyOn(sheet, 'indexize').mockReturnValue(undefined); + + await SpriteSheet.loadIndexed('hero.png', palette, 4, { sort: 'none' }); + + expect(loadColorsSpy).toHaveBeenCalledWith('hero.png', palette, 4, { sort: 'none' }); + }); + }); + + // #endregion + // #region loadColorsIntoPalette describe('loadColorsIntoPalette', () => { diff --git a/src/assets/SpriteSheet.ts b/src/assets/SpriteSheet.ts index 766b329..8389d83 100644 --- a/src/assets/SpriteSheet.ts +++ b/src/assets/SpriteSheet.ts @@ -1,9 +1,23 @@ import { Color32 } from '../utils/Color32'; -import type { Rect2i } from '../utils/Rect2i'; +import { Rect2i } from '../utils/Rect2i'; import { Vector2i } from '../utils/Vector2i'; import { AssetLoader } from './AssetLoader'; import type { Palette } from './Palette'; +/** + * Result object returned by {@link SpriteSheet.loadIndexed}. + */ +export type IndexedSpriteLoadResult = { + /** Loaded and indexized sprite sheet. */ + sheet: SpriteSheet; + + /** Full-frame source rectangle matching the loaded image size. */ + srcRect: Rect2i; + + /** Colors registered into the palette in write order. */ + colors: Color32[]; +}; + /** * Sprite-sheet wrapper around a loaded image asset. * @@ -98,6 +112,43 @@ export class SpriteSheet { return sheet; } + /** + * Convenience one-call path for palette-indexed sprite setup. + * + * This combines: + * 1) {@link SpriteSheet.loadColorsIntoPalette} + * 2) {@link SpriteSheet.load} + * 3) {@link SpriteSheet.indexize} + * + * It returns the indexized sheet plus a full-frame source rectangle and the + * colors that were written into the palette. Callers still control when to + * activate the palette via `BT.paletteSet(palette)`. + * + * @param url - Path or URL to the PNG file. + * @param palette - Target palette used for both registration and indexization. + * @param startSlot - First palette slot to write discovered colors into. + * @param options - Optional color-sort behavior for registration. + * @param options.sort - Color ordering for palette registration. + * @returns Object with `sheet`, `srcRect`, and registered `colors`. + */ + static async loadIndexed( + url: string, + palette: Palette, + startSlot: number, + options?: { sort?: 'luminance' | 'none' }, + ): Promise { + const colors = await SpriteSheet.loadColorsIntoPalette(url, palette, startSlot, options); + const sheet = await SpriteSheet.load(url); + + sheet.indexize(palette); + + return { + sheet, + srcRect: new Rect2i(0, 0, sheet.size.x, sheet.size.y), + colors, + }; + } + /** * Walks a PNG's pixels and registers every unique opaque color into the * supplied palette starting at `startSlot`. diff --git a/src/utils/CameraUtils.test.ts b/src/utils/CameraUtils.test.ts new file mode 100644 index 0000000..7a6a9a6 --- /dev/null +++ b/src/utils/CameraUtils.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { clampCameraToWorld } from './CameraUtils'; +import { Vector2i } from './Vector2i'; + +describe('clampCameraToWorld', () => { + it('clamps camera inside positive world bounds', () => { + const camera = new Vector2i(500, 300); + const world = new Vector2i(640, 480); + const view = new Vector2i(320, 240); + + const clamped = clampCameraToWorld(camera, world, view); + + expect(clamped.equalsXY(320, 240)).toBe(true); + }); + + it('clamps negative camera coordinates to zero', () => { + const camera = new Vector2i(-20, -10); + const world = new Vector2i(1000, 1000); + const view = new Vector2i(320, 240); + + const clamped = clampCameraToWorld(camera, world, view); + + expect(clamped.equalsXY(0, 0)).toBe(true); + }); + + it('pins axis to zero when world is smaller than viewport', () => { + const camera = new Vector2i(30, 40); + const world = new Vector2i(200, 100); + const view = new Vector2i(320, 240); + + const clamped = clampCameraToWorld(camera, world, view); + + expect(clamped.equalsXY(0, 0)).toBe(true); + }); +}); diff --git a/src/utils/CameraUtils.ts b/src/utils/CameraUtils.ts new file mode 100644 index 0000000..7bdc312 --- /dev/null +++ b/src/utils/CameraUtils.ts @@ -0,0 +1,20 @@ +import { Vector2i } from './Vector2i'; + +/** + * Clamps a camera origin so a viewport stays within world bounds. + * + * `camera` is interpreted as the viewport top-left in world coordinates. + * The result is clamped to `[0, max(world - view)]` per axis. When the + * world is smaller than the viewport on an axis, that axis clamps to `0`. + * + * @param camera - Desired camera origin in world coordinates. + * @param worldSize - Full world size in pixels. + * @param viewSize - Viewport size in pixels. + * @returns Clamped camera origin. + */ +export function clampCameraToWorld(camera: Vector2i, worldSize: Vector2i, viewSize: Vector2i): Vector2i { + const maxX = Math.max(0, worldSize.x - viewSize.x); + const maxY = Math.max(0, worldSize.y - viewSize.y); + + return Vector2i.fromXYUnchecked(Math.max(0, Math.min(maxX, camera.x)), Math.max(0, Math.min(maxY, camera.y))); +}