diff --git a/scripts/convert-system-font.mjs b/scripts/convert-system-font.mjs new file mode 100644 index 0000000..22daded --- /dev/null +++ b/scripts/convert-system-font.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node + +/** + * System Font PNG to TypeScript Converter + * + * Reads a 96x84 PNG atlas (16 columns x 6 rows of 6x14 glyphs, ASCII 32-126) + * and regenerates src/assets/fonts/systemFontData.ts with the corresponding + * bit-pattern data. + * + * Any pixel whose red channel is >= 128 is treated as "on" (foreground). + * Everything else is "off" (transparent). + * + * Usage: + * node scripts/convert-system-font.mjs [input-path] + * + * Default input: assets/system-font.png + * Output: src/assets/fonts/systemFontData.ts (always overwritten) + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { PNG } from 'pngjs'; + +// #region Constants + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = resolve(__dirname, '..'); +const DEFAULT_INPUT = join(PROJECT_ROOT, 'assets/system-font.png'); +const OUTPUT_PATH = join(PROJECT_ROOT, 'src/assets/fonts/systemFontData.ts'); + +const GLYPH_WIDTH = 6; +const GLYPH_HEIGHT = 14; +const ATLAS_COLS = 16; +const ATLAS_ROWS = 6; +const ATLAS_WIDTH = ATLAS_COLS * GLYPH_WIDTH; // 96 +const ATLAS_HEIGHT = ATLAS_ROWS * GLYPH_HEIGHT; // 84 +const FIRST_CHAR = 32; +const LAST_CHAR = 126; +const GLYPH_COUNT = LAST_CHAR - FIRST_CHAR + 1; // 95 +const ON_THRESHOLD = 128; // Red channel >= this means "on". + +// #endregion + +// #region PNG Reading + +/** + * Reads the PNG and extracts bit patterns for all 95 glyphs. + * + * @param {string} inputPath - Path to the 96x84 PNG atlas. + * @returns {number[]} Flat array of 1330 bytes (95 glyphs x 14 rows). + */ +function extractBitmaps(inputPath) { + const pngData = readFileSync(inputPath); + const png = PNG.sync.read(pngData); + + if (png.width !== ATLAS_WIDTH || png.height !== ATLAS_HEIGHT) { + console.error(`Error: Expected ${ATLAS_WIDTH}x${ATLAS_HEIGHT} PNG, got ${png.width}x${png.height}.`); + process.exit(1); + } + + const bitmaps = []; + + for (let glyphIndex = 0; glyphIndex < GLYPH_COUNT; glyphIndex++) { + const col = glyphIndex % ATLAS_COLS; + const row = Math.floor(glyphIndex / ATLAS_COLS); + const baseX = col * GLYPH_WIDTH; + const baseY = row * GLYPH_HEIGHT; + + for (let py = 0; py < GLYPH_HEIGHT; py++) { + let byte = 0; + + for (let px = 0; px < GLYPH_WIDTH; px++) { + const x = baseX + px; + const y = baseY + py; + const idx = (y * ATLAS_WIDTH + x) * 4; + // eslint-disable-next-line security/detect-object-injection + const red = png.data[idx]; + + // Bit 7 = leftmost pixel, bit 0 = rightmost. + if (red >= ON_THRESHOLD) { + byte |= 1 << (7 - px); + } + } + + bitmaps.push(byte); + } + } + + return bitmaps; +} + +// #endregion + +// #region TypeScript Generation + +/** + * Returns the printable label for a character code. + * + * @param {number} charCode - ASCII character code. + * @returns {string} Human-readable label (e.g., "A (65)" or "Space (32)"). + */ +function charLabel(charCode) { + if (charCode === 32) { + return 'Space (32)'; + } + + return `${String.fromCharCode(charCode)} (${charCode})`; +} + +/** + * Formats a byte as a two-digit hex string with 0x prefix. + * + * @param {number} value - Byte value (0-255). + * @returns {string} Formatted hex string. + */ +function hex(value) { + return `0x${value.toString(16).padStart(2, '0')}`; +} + +/** + * Generates the TypeScript source for systemFontData.ts. + * + * @param {number[]} bitmaps - Flat array of 1330 bytes. + * @returns {string} Complete TypeScript source file content. + */ +function generateTypeScript(bitmaps) { + const lines = []; + + lines.push('/**'); + lines.push(' * Built-in system font bitmap data.'); + lines.push(' *'); + lines.push(' * Covers printable ASCII characters 32-126 (95 glyphs). Each glyph is'); + lines.push(' * 14 bytes, one byte per row (top to bottom). Bit 7 is the leftmost pixel;'); + lines.push(' * only the top 6 bits (bits 7-2) are used for the 6-pixel glyph width.'); + lines.push(' * A set bit means palette index 1 (opaque foreground); a clear bit means'); + lines.push(' * index 0 (transparent).'); + lines.push(' *'); + lines.push(' * The data is a flat array of `95 * 14 = 1330` bytes. Access glyph for'); + lines.push(' * character code `c` at offset `(c - 32) * 14`.'); + lines.push(' *'); + lines.push(' * This file is auto-generated by `pnpm system-font:convert` from the PNG'); + lines.push(' * atlas at `assets/system-font.png`. To edit the font visually, modify the'); + lines.push(' * PNG in a pixel editor and re-run the conversion script. To export the'); + lines.push(' * current data back to PNG, use `pnpm system-font:export`.'); + lines.push(' *'); + lines.push(' * This font data is in the public domain.'); + lines.push(' */'); + lines.push(''); + lines.push('// prettier-ignore'); + lines.push('export const SYSTEM_FONT_BITMAPS: readonly number[] = ['); + + for (let glyphIndex = 0; glyphIndex < GLYPH_COUNT; glyphIndex++) { + const charCode = FIRST_CHAR + glyphIndex; + const offset = glyphIndex * GLYPH_HEIGHT; + const bytes = bitmaps.slice(offset, offset + GLYPH_HEIGHT); + const hexValues = bytes.map((b) => hex(b)).join(', '); + + lines.push(` // ${charLabel(charCode)}`); + lines.push(` ${hexValues},`); + } + + lines.push('];'); + lines.push(''); + lines.push('/** First character code in the bitmap array. */'); + lines.push('export const SYSTEM_FONT_FIRST_CHAR = 32;'); + lines.push(''); + lines.push('/** Last character code in the bitmap array (inclusive). */'); + lines.push('export const SYSTEM_FONT_LAST_CHAR = 126;'); + lines.push(''); + lines.push('/** Number of glyphs in the system font. */'); + lines.push('export const SYSTEM_FONT_GLYPH_COUNT = SYSTEM_FONT_LAST_CHAR - SYSTEM_FONT_FIRST_CHAR + 1;'); + lines.push(''); + lines.push('/** Width of each glyph in pixels. */'); + lines.push('export const SYSTEM_FONT_GLYPH_WIDTH = 6;'); + lines.push(''); + lines.push('/** Height of each glyph in pixels. */'); + lines.push('export const SYSTEM_FONT_GLYPH_HEIGHT = 14;'); + lines.push(''); + lines.push('/** Number of bytes per glyph (one byte per row). */'); + lines.push('export const SYSTEM_FONT_BYTES_PER_GLYPH = 14;'); + lines.push(''); + + return lines.join('\n'); +} + +// #endregion + +// #region Main + +function main() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(` +System Font PNG to TypeScript Converter + +Reads a 96x84 PNG atlas and regenerates systemFontData.ts. +Layout: 16 columns x 6 rows of 6x14 glyphs (ASCII 32-126). +Any pixel with red channel >= 128 is treated as foreground. + +Usage: + node scripts/convert-system-font.mjs [input-path] + +Default input: assets/system-font.png +Output: src/assets/fonts/systemFontData.ts (always overwritten) + +Options: + -h, --help Show this help message +`); + process.exit(0); + } + + const inputPath = args[0] || DEFAULT_INPUT; + const bitmaps = extractBitmaps(inputPath); + const source = generateTypeScript(bitmaps); + + writeFileSync(OUTPUT_PATH, source); + + console.log(`Converted system font from: ${inputPath}`); + console.log(` Output: ${OUTPUT_PATH}`); + console.log(` Glyphs: ${GLYPH_COUNT} (ASCII ${FIRST_CHAR}-${LAST_CHAR})`); + console.log(` Atlas: ${ATLAS_WIDTH}x${ATLAS_HEIGHT} pixels (${ATLAS_COLS}x${ATLAS_ROWS} grid)`); +} + +main(); + +// #endregion diff --git a/src/BlitTech.test.ts b/src/BlitTech.test.ts index dd274d4..36723e0 100644 --- a/src/BlitTech.test.ts +++ b/src/BlitTech.test.ts @@ -397,51 +397,45 @@ describe('BT.keyReleased', () => { // #endregion -// #region BT.print / BT.printMeasure / BT.printFont +// #region BT.systemPrint / BT.systemPrintMeasure / BT.printFont -describe('BT.print', () => { +describe('BT.systemPrint', () => { beforeEach(() => { vi.restoreAllMocks(); }); - it('delegates to BTAPI.instance.drawText', () => { - const spy = vi.spyOn(BTAPI.instance, 'drawText').mockReturnValue(undefined); + it('delegates to BTAPI.instance.drawSystemText', () => { + const spy = vi.spyOn(BTAPI.instance, 'drawSystemText').mockReturnValue(undefined); const pos = new Vector2i(10, 10); - BT.print(pos, 8, 'Hello'); + BT.systemPrint(pos, 8, 'Hello'); expect(spy).toHaveBeenCalledWith(pos, 8, 'Hello'); }); }); -describe('BT.printMeasure', () => { - it('returns a zero vector', () => { - vi.spyOn(console, 'warn').mockReturnValue(undefined); +describe('BT.systemPrintMeasure', () => { + it('returns zero vector when system font is not initialized', () => { + vi.spyOn(BTAPI.instance, 'getSystemFont').mockReturnValue(null); - const result = BT.printMeasure('any text'); + const result = BT.systemPrintMeasure('any text'); expect(result.x).toBe(0); expect(result.y).toBe(0); }); - it('emits a console warning on the first call', () => { - const warnSpy = vi.spyOn(console, 'warn').mockReturnValue(undefined); - - BT.printMeasure('first'); - - // The warnOnce mechanism may have already fired in a previous test in this - // file. Either way, warn is called at most once per unique function name. - expect(warnSpy.mock.calls.length).toBeLessThanOrEqual(1); - }); + it('returns measured size when system font is available', () => { + const mockFont = { + measureTextSize: vi.fn().mockReturnValue({ width: 40, height: 8 }), + }; - it('does not emit a second warning for the same call', () => { - const warnSpy = vi.spyOn(console, 'warn').mockReturnValue(undefined); + vi.spyOn(BTAPI.instance, 'getSystemFont').mockReturnValue(mockFont as unknown as BitmapFont); - BT.printMeasure('again'); - BT.printMeasure('again'); + const result = BT.systemPrintMeasure('Hello'); - // warnOnce suppresses duplicates; total warn calls across both invocations are 0 or 1. - expect(warnSpy.mock.calls.length).toBeLessThanOrEqual(1); + expect(result.x).toBe(40); + expect(result.y).toBe(8); + expect(mockFont.measureTextSize).toHaveBeenCalledWith('Hello'); }); }); diff --git a/src/BlitTech.ts b/src/BlitTech.ts index 8998d99..62c2fb2 100644 --- a/src/BlitTech.ts +++ b/src/BlitTech.ts @@ -27,31 +27,6 @@ import { applyEasing } from './utils/Easing'; import { Rect2i } from './utils/Rect2i'; import { Vector2i } from './utils/Vector2i'; -// #region Module State - -/** Tracks one-time facade warnings to avoid repeated console noise. */ -const _warnedFunctions = new Set(); - -// #endregion - -// #region Helper Functions - -/** - * Emits a warning message only once for a named facade function. - * - * @param funcName - Unique identifier for the function (used for deduplication). - * @param message - Warning message to display in the console. - */ -function warnOnce(funcName: string, message: string): void { - if (!_warnedFunctions.has(funcName)) { - console.warn(message); - - _warnedFunctions.add(funcName); - } -} - -// #endregion - // #region Public API /** Main Blit-Tech API namespace used by runtime demos. */ @@ -532,31 +507,36 @@ export const BT = { // #region Text Rendering /** - * Draws basic placeholder text. + * Draws text using the built-in 6x14 system font. * - * This uses the engine's fallback text rendering. For authored bitmap fonts, - * prefer {@link BT.printFont}. + * The system font covers printable ASCII (characters 32-126). For custom + * bitmap fonts with proportional glyphs, use {@link BT.printFont} instead. * * @param pos - Text origin in display coordinates. - * @param paletteIndex - Palette color index. + * @param paletteIndex - Palette color index for the text. * @param text - String to render. */ - print: (pos: Vector2i, paletteIndex: number, text: string): void => { - BTAPI.instance.drawText(pos, paletteIndex, text); + systemPrint: (pos: Vector2i, paletteIndex: number, text: string): void => { + BTAPI.instance.drawSystemText(pos, paletteIndex, text); }, /** - * Measures fallback text dimensions. + * Measures the pixel dimensions of a string rendered with the built-in + * system font. * - * This API is not implemented yet and currently returns `Vector2i.zero()`. - * - * @param _text - Text string to measure. - * @returns Text size in pixels. Returns `Vector2i.zero()` until measurement support is implemented. + * @param text - Text string to measure. + * @returns Width and height in pixels, or `Vector2i.zero()` before engine initialization. */ - printMeasure: (_text: string): Vector2i => { - warnOnce('printMeasure', '[BT.printMeasure] Not yet implemented'); + systemPrintMeasure: (text: string): Vector2i => { + const font = BTAPI.instance.getSystemFont(); + + if (!font) { + return Vector2i.zero(); + } + + const size = font.measureTextSize(text); - return Vector2i.zero(); + return new Vector2i(size.width, size.height); }, /** diff --git a/src/assets/BitmapFont.test.ts b/src/assets/BitmapFont.test.ts index ff3d266..52c1245 100644 --- a/src/assets/BitmapFont.test.ts +++ b/src/assets/BitmapFont.test.ts @@ -14,7 +14,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Rect2i } from '../utils/Rect2i'; import { BitmapFont } from './BitmapFont'; +import { SpriteSheet } from './SpriteSheet'; // #region Test Fixtures @@ -421,6 +423,59 @@ describe('BitmapFont', () => { }); // #endregion + + // #region createFromGlyphs + + describe('createFromGlyphs', () => { + it('creates a font with correct metadata', () => { + const pixels = new Uint8Array(16 * 16) as Uint8Array; + const sheet = SpriteSheet.fromIndexedPixels(16, 16, pixels); + + const glyphs = new Map(); + + glyphs.set('A', { rect: new Rect2i(0, 0, 8, 8), offsetX: 0, offsetY: 0, advance: 8 }); + glyphs.set('B', { rect: new Rect2i(8, 0, 8, 8), offsetX: 0, offsetY: 0, advance: 8 }); + + const font = BitmapFont.createFromGlyphs(sheet, glyphs, 'TestFont', 8, 10, 8); + + expect(font.name).toBe('TestFont'); + expect(font.size).toBe(8); + expect(font.lineHeight).toBe(10); + expect(font.baseline).toBe(8); + expect(font.glyphCount).toBe(2); + }); + + it('populates ASCII fast-path for single-byte characters', () => { + const pixels = new Uint8Array(16 * 16) as Uint8Array; + const sheet = SpriteSheet.fromIndexedPixels(16, 16, pixels); + + const glyphs = new Map(); + + glyphs.set('A', { rect: new Rect2i(0, 0, 8, 8), offsetX: 0, offsetY: 0, advance: 8 }); + + const font = BitmapFont.createFromGlyphs(sheet, glyphs, 'Test', 8, 8, 8); + + expect(font.getGlyph('A')).not.toBeNull(); + expect(font.getGlyphByCode(65)).not.toBeNull(); + expect(font.getGlyph('A')).toBe(font.getGlyphByCode(65)); + }); + + it('measures text correctly', () => { + const pixels = new Uint8Array(16 * 16) as Uint8Array; + const sheet = SpriteSheet.fromIndexedPixels(16, 16, pixels); + + const glyphs = new Map(); + + glyphs.set('H', { rect: new Rect2i(0, 0, 8, 8), offsetX: 0, offsetY: 0, advance: 8 }); + glyphs.set('i', { rect: new Rect2i(8, 0, 8, 8), offsetX: 0, offsetY: 0, advance: 6 }); + + const font = BitmapFont.createFromGlyphs(sheet, glyphs, 'Test', 8, 8, 8); + + expect(font.measureText('Hi')).toBe(14); // 8 + 6 + }); + }); + + // #endregion }); // #endregion diff --git a/src/assets/BitmapFont.ts b/src/assets/BitmapFont.ts index 076d7a4..ed4f9f0 100644 --- a/src/assets/BitmapFont.ts +++ b/src/assets/BitmapFont.ts @@ -191,6 +191,50 @@ export class BitmapFont { // #endregion + // #region Static Factories + + /** + * Creates a bitmap font synchronously from pre-built glyph data. + * + * Used for embedded fonts (e.g. the built-in system font) where the sprite + * sheet and glyph map are already constructed in memory. The sprite sheet + * should already contain indexed pixel data via + * {@link SpriteSheet.fromIndexedPixels}. + * + * @param spriteSheet - Texture atlas containing all font glyphs. + * @param glyphs - Map of character strings to glyph metadata. + * @param name - Font display name. + * @param size - Font size in points. + * @param lineHeight - Vertical spacing between lines in pixels. + * @param baseline - Distance from top to text baseline in pixels. + * @returns Fully constructed BitmapFont ready for rendering. + */ + static createFromGlyphs( + spriteSheet: SpriteSheet, + glyphs: Map, + name: string, + size: number, + lineHeight: number, + baseline: number, + ): BitmapFont { + const asciiGlyphs: (Glyph | null)[] = new Array(ASCII_CACHE_SIZE).fill(null); + + for (const [char, glyph] of glyphs) { + if (char.length === 1) { + const code = char.charCodeAt(0); + + if (code < ASCII_CACHE_SIZE) { + // eslint-disable-next-line security/detect-object-injection -- Index is bounds-checked above + asciiGlyphs[code] = glyph; + } + } + } + + return new BitmapFont(spriteSheet, glyphs, asciiGlyphs, name, size, lineHeight, baseline); + } + + // #endregion + // #region Loading /** diff --git a/src/assets/SpriteSheet.test.ts b/src/assets/SpriteSheet.test.ts index 7ec6f5b..b5e3ca5 100644 --- a/src/assets/SpriteSheet.test.ts +++ b/src/assets/SpriteSheet.test.ts @@ -344,4 +344,58 @@ describe('SpriteSheet', () => { }); // #endregion + + // #region fromIndexedPixels + + describe('fromIndexedPixels', () => { + it('creates a sheet with correct dimensions', () => { + const pixels = new Uint8Array(16 * 16) as Uint8Array; + const sheet = SpriteSheet.fromIndexedPixels(16, 16, pixels); + + expect(sheet.size.x).toBe(16); + expect(sheet.size.y).toBe(16); + }); + + it('marks the sheet as indexized', () => { + const pixels = new Uint8Array(8 * 8) as Uint8Array; + const sheet = SpriteSheet.fromIndexedPixels(8, 8, pixels); + + expect(sheet.isIndexized()).toBe(true); + }); + + it('throws on indexize (no source image)', () => { + const pixels = new Uint8Array(4 * 4) as Uint8Array; + const sheet = SpriteSheet.fromIndexedPixels(4, 4, pixels); + const palette = new Palette(16); + + expect(() => sheet.indexize(palette)).toThrow('not available for sheets created from raw indexed data'); + }); + + it('throws on reindexize (no source image)', () => { + const pixels = new Uint8Array(4 * 4) as Uint8Array; + const sheet = SpriteSheet.fromIndexedPixels(4, 4, pixels); + const palette = new Palette(16); + + expect(() => sheet.reindexize(palette)).toThrow('not available for sheets created from raw indexed data'); + }); + + it('throws on getImage (no source image)', () => { + const pixels = new Uint8Array(4 * 4) as Uint8Array; + const sheet = SpriteSheet.fromIndexedPixels(4, 4, pixels); + + expect(() => sheet.getImage()).toThrow('not available for sheets created from raw indexed data'); + }); + + it('creates r8uint GPU texture via getTexture', () => { + const device = createMockGPUDevice(); + const pixels = new Uint8Array(4 * 4) as Uint8Array; + const sheet = SpriteSheet.fromIndexedPixels(4, 4, pixels); + + const texture = sheet.getTexture(device); + + expect(texture).toBeDefined(); + }); + }); + + // #endregion }); diff --git a/src/assets/SpriteSheet.ts b/src/assets/SpriteSheet.ts index c2a753c..0e54e4d 100644 --- a/src/assets/SpriteSheet.ts +++ b/src/assets/SpriteSheet.ts @@ -23,8 +23,8 @@ export class SpriteSheet { /** Sprite sheet dimensions in pixels. */ public readonly size: Vector2i; - /** Source HTML image element. */ - private readonly image: HTMLImageElement; + /** Source HTML image element (null for sheets created from raw indexed data). */ + private readonly image: HTMLImageElement | null; /** Pre-decoded image bitmap for GPU upload (created by load()). */ private imageBitmap: ImageBitmap | null = null; @@ -46,11 +46,19 @@ export class SpriteSheet { * Creates a sprite sheet from a loaded image. * Use the static load() method for easier loading from URL. * - * @param image - Pre-loaded HTMLImageElement. + * @param image - Pre-loaded HTMLImageElement, or null for raw indexed data sheets. + * @param size - Explicit dimensions (required when image is null). */ - constructor(image: HTMLImageElement) { + constructor(image: HTMLImageElement | null, size?: Vector2i) { this.image = image; - this.size = new Vector2i(image.width, image.height); + + if (image) { + this.size = new Vector2i(image.width, image.height); + } else if (size) { + this.size = size; + } else { + throw new Error('[SpriteSheet] Either an image or explicit size must be provided.'); + } } // #endregion @@ -90,6 +98,30 @@ export class SpriteSheet { return sheet; } + /** + * Creates a sprite sheet from pre-computed palette-indexed pixel data. + * + * The resulting sheet is immediately indexized -- its `getTexture()` call + * will produce an `r8uint` GPU texture without needing `indexize()`. This + * is used for embedded assets like the built-in system font where the pixel + * data is already expressed as palette indices. + * + * Sheets created this way do not support `indexize()` or `reindexize()` + * because there is no source RGBA data to re-map. + * + * @param width - Texture width in pixels. + * @param height - Texture height in pixels. + * @param indexedPixels - Flat array of palette indices, one byte per pixel (row-major). + * @returns Sprite sheet ready for rendering. + */ + static fromIndexedPixels(width: number, height: number, indexedPixels: Uint8Array): SpriteSheet { + const sheet = new SpriteSheet(null, new Vector2i(width, height)); + + sheet.indexedPixels = indexedPixels; + + return sheet; + } + // #endregion // #region Indexization @@ -109,6 +141,10 @@ export class SpriteSheet { * @throws If any opaque pixel's color is not present in the palette. */ indexize(palette: Palette): void { + if (!this.image) { + throw new Error('[SpriteSheet] indexize: not available for sheets created from raw indexed data.'); + } + const w = this.size.x; const h = this.size.y; @@ -211,6 +247,10 @@ export class SpriteSheet { * @throws If any opaque pixel's color is not found in the new palette. */ reindexize(palette: Palette): void { + if (!this.image) { + throw new Error('[SpriteSheet] reindexize: not available for sheets created from raw indexed data.'); + } + if (this.rgbaPixels === null) { throw new Error('[SpriteSheet] reindexize: indexize() must be called before reindexize().'); } @@ -283,8 +323,13 @@ export class SpriteSheet { * Gets the source HTMLImageElement. * * @returns The underlying image element. + * @throws If the sheet was created from raw indexed data (no source image). */ getImage(): HTMLImageElement { + if (!this.image) { + throw new Error('[SpriteSheet] getImage: not available for sheets created from raw indexed data.'); + } + return this.image; } @@ -368,6 +413,10 @@ export class SpriteSheet { * @param device - WebGPU device for texture creation. */ private createTexture(device: GPUDevice): void { + if (!this.image) { + throw new Error('[SpriteSheet] createTexture: no source image available.'); + } + this.texture = device.createTexture({ label: 'Sprite Sheet Texture', size: [this.size.x, this.size.y, 1], diff --git a/src/assets/SystemFont.bench.ts b/src/assets/SystemFont.bench.ts new file mode 100644 index 0000000..d104b65 --- /dev/null +++ b/src/assets/SystemFont.bench.ts @@ -0,0 +1,120 @@ +/** + * Tier 1 CPU benchmarks for the built-in system font. + * + * Measures: + * - `createSystemFont()` construction time (atlas expansion + BitmapFont creation) + * - Glyph lookup throughput (ASCII fast-path) + * - Text measurement throughput + */ + +// #region Imports + +import { bench, describe } from 'vitest'; + +import { createSystemFont } from './SystemFont'; + +// #endregion + +// #region Constants + +const BENCH_OPTIONS = { + iterations: 500, + time: 100, + warmupTime: 25, + warmupIterations: 50, +}; + +const SHORT_TEXT = 'Hello, World!'; +const LONG_TEXT = 'The quick brown fox jumps over the lazy dog. 0123456789 ~!@#$%^&*()'; + +// #endregion + +// #region Creation Benchmarks + +describe('SystemFont creation', () => { + bench( + 'createSystemFont()', + () => { + createSystemFont(); + }, + BENCH_OPTIONS, + ); +}); + +// #endregion + +// #region Glyph Lookup Benchmarks + +describe('SystemFont glyph lookup', () => { + const font = createSystemFont(); + + bench( + 'getGlyph (single char)', + () => { + font.getGlyph('A'); + }, + BENCH_OPTIONS, + ); + + bench( + 'getGlyphByCode (ASCII fast-path)', + () => { + font.getGlyphByCode(65); + }, + BENCH_OPTIONS, + ); + + bench( + 'getGlyph for all printable ASCII', + () => { + for (let code = 32; code <= 126; code++) { + font.getGlyphByCode(code); + } + }, + BENCH_OPTIONS, + ); +}); + +// #endregion + +// #region Text Measurement Benchmarks + +describe('SystemFont text measurement', () => { + const font = createSystemFont(); + + bench( + 'measureText (short string, cold cache)', + () => { + font.clearMeasureCache(); + font.measureText(SHORT_TEXT); + }, + BENCH_OPTIONS, + ); + + bench( + 'measureText (short string, warm cache)', + () => { + font.measureText(SHORT_TEXT); + }, + BENCH_OPTIONS, + ); + + bench( + 'measureText (long string, cold cache)', + () => { + font.clearMeasureCache(); + font.measureText(LONG_TEXT); + }, + BENCH_OPTIONS, + ); + + bench( + 'measureTextSize (short string)', + () => { + font.measureTextSize(SHORT_TEXT); + }, + BENCH_OPTIONS, + ); +}); + +// #endregion diff --git a/src/assets/SystemFont.test.ts b/src/assets/SystemFont.test.ts new file mode 100644 index 0000000..4bdda96 --- /dev/null +++ b/src/assets/SystemFont.test.ts @@ -0,0 +1,178 @@ +/** + * Unit tests for the built-in system font. + * + * Covers: + * - `createSystemFont()` produces a valid BitmapFont + * - Correct glyph count (95 printable ASCII characters) + * - Glyph lookup for representative characters + * - Text measurement via the system font + * - Atlas texture dimensions + */ + +import { describe, expect, it } from 'vitest'; + +import { + SYSTEM_FONT_FIRST_CHAR, + SYSTEM_FONT_GLYPH_COUNT, + SYSTEM_FONT_GLYPH_HEIGHT, + SYSTEM_FONT_GLYPH_WIDTH, + SYSTEM_FONT_LAST_CHAR, +} from './fonts/systemFontData'; +import { Palette } from './Palette'; +import { createSystemFont } from './SystemFont'; + +// #region Factory + +describe('createSystemFont', () => { + it('returns a BitmapFont instance', () => { + const font = createSystemFont(); + + expect(font).toBeDefined(); + expect(font.name).toBe('System'); + }); + + it('has correct metadata', () => { + const font = createSystemFont(); + + expect(font.size).toBe(SYSTEM_FONT_GLYPH_HEIGHT); + expect(font.lineHeight).toBe(SYSTEM_FONT_GLYPH_HEIGHT); + expect(font.baseline).toBe(SYSTEM_FONT_GLYPH_HEIGHT); + }); + + it('contains all 95 printable ASCII glyphs', () => { + const font = createSystemFont(); + + expect(font.glyphCount).toBe(SYSTEM_FONT_GLYPH_COUNT); + }); + + it('has glyphs for all characters from space to tilde', () => { + const font = createSystemFont(); + + for (let code = SYSTEM_FONT_FIRST_CHAR; code <= SYSTEM_FONT_LAST_CHAR; code++) { + const char = String.fromCharCode(code); + + expect(font.hasGlyph(char)).toBe(true); + } + }); +}); + +// #endregion + +// #region Glyph Access + +describe('system font glyph access', () => { + it('returns glyph for space character', () => { + const font = createSystemFont(); + const glyph = font.getGlyph(' '); + + expect(glyph).not.toBeNull(); + expect(glyph?.rect.width).toBe(SYSTEM_FONT_GLYPH_WIDTH); + expect(glyph?.rect.height).toBe(SYSTEM_FONT_GLYPH_HEIGHT); + expect(glyph?.advance).toBe(SYSTEM_FONT_GLYPH_WIDTH); + }); + + it('returns glyph for letter A', () => { + const font = createSystemFont(); + const glyph = font.getGlyph('A'); + + expect(glyph).not.toBeNull(); + expect(glyph?.offsetX).toBe(0); + expect(glyph?.offsetY).toBe(0); + }); + + it('returns glyph by character code', () => { + const font = createSystemFont(); + const glyphA = font.getGlyphByCode(65); // 'A' + const glyphDirect = font.getGlyph('A'); + + expect(glyphA).toBe(glyphDirect); + }); + + it('returns null for non-ASCII character', () => { + const font = createSystemFont(); + + expect(font.getGlyph('\u00e9')).toBeNull(); // e-acute + }); + + it('returns null for control characters', () => { + const font = createSystemFont(); + + expect(font.getGlyphByCode(0)).toBeNull(); + expect(font.getGlyphByCode(31)).toBeNull(); + }); +}); + +// #endregion + +// #region Text Measurement + +describe('system font text measurement', () => { + it('measures text width correctly', () => { + const font = createSystemFont(); + const width = font.measureText('Hello'); + + // 5 characters * 6 pixels each + expect(width).toBe(5 * SYSTEM_FONT_GLYPH_WIDTH); + }); + + it('measures empty string as zero', () => { + const font = createSystemFont(); + + expect(font.measureText('')).toBe(0); + }); + + it('measureTextSize returns width and height', () => { + const font = createSystemFont(); + const size = font.measureTextSize('Test'); + + expect(size.width).toBe(4 * SYSTEM_FONT_GLYPH_WIDTH); + expect(size.height).toBe(SYSTEM_FONT_GLYPH_HEIGHT); + }); +}); + +// #endregion + +// #region Sprite Sheet + +describe('system font sprite sheet', () => { + it('produces an indexized sprite sheet', () => { + const font = createSystemFont(); + const sheet = font.getSpriteSheet(); + + expect(sheet.isIndexized()).toBe(true); + }); + + it('sprite sheet has correct atlas dimensions', () => { + const font = createSystemFont(); + const sheet = font.getSpriteSheet(); + + // 16 columns * 6px = 96 wide, 6 rows * 14px = 84 tall + expect(sheet.size.x).toBe(96); + expect(sheet.size.y).toBe(84); + }); + + it('sprite sheet throws on indexize (no source image)', () => { + const font = createSystemFont(); + const sheet = font.getSpriteSheet(); + const palette = new Palette(256); + + expect(() => sheet.indexize(palette)).toThrow('not available for sheets created from raw indexed data'); + }); + + it('sprite sheet throws on reindexize (no source image)', () => { + const font = createSystemFont(); + const sheet = font.getSpriteSheet(); + const palette = new Palette(256); + + expect(() => sheet.reindexize(palette)).toThrow('not available for sheets created from raw indexed data'); + }); + + it('sprite sheet throws on getImage (no source image)', () => { + const font = createSystemFont(); + const sheet = font.getSpriteSheet(); + + expect(() => sheet.getImage()).toThrow('not available for sheets created from raw indexed data'); + }); +}); + +// #endregion diff --git a/src/assets/SystemFont.ts b/src/assets/SystemFont.ts new file mode 100644 index 0000000..8baf09a --- /dev/null +++ b/src/assets/SystemFont.ts @@ -0,0 +1,146 @@ +/** + * Built-in 6x14 system font for {@link BT.systemPrint}. + * + * Expands the embedded glyph bitmaps from {@link systemFontData} into a + * palette-indexed texture atlas and wraps the result in a {@link BitmapFont}. + * The font is fully synchronous to create -- no `fetch()`, no image decode. + * + * The glyph data lives in `src/assets/fonts/systemFontData.ts`. To edit it + * visually, export the current bitmaps to a PNG, redraw in a pixel editor, + * then convert back: + * + * ```bash + * pnpm system-font:export # writes assets/system-font.png + * # edit assets/system-font.png in a pixel editor + * pnpm system-font:convert # regenerates systemFontData.ts from the PNG + * ``` + */ + +import { Rect2i } from '../utils/Rect2i'; +import type { Glyph } from './BitmapFont'; +import { BitmapFont } from './BitmapFont'; +import { + SYSTEM_FONT_BITMAPS, + SYSTEM_FONT_BYTES_PER_GLYPH, + SYSTEM_FONT_FIRST_CHAR, + SYSTEM_FONT_GLYPH_COUNT, + SYSTEM_FONT_GLYPH_HEIGHT, + SYSTEM_FONT_GLYPH_WIDTH, +} from './fonts/systemFontData'; +import { SpriteSheet } from './SpriteSheet'; + +// #region Constants + +/** Number of glyph columns in the texture atlas. */ +const ATLAS_COLUMNS = 16; + +/** Number of glyph rows in the texture atlas. */ +const ATLAS_ROWS = Math.ceil(SYSTEM_FONT_GLYPH_COUNT / ATLAS_COLUMNS); + +/** Atlas width in pixels. */ +const ATLAS_WIDTH = ATLAS_COLUMNS * SYSTEM_FONT_GLYPH_WIDTH; + +/** Atlas height in pixels. */ +const ATLAS_HEIGHT = ATLAS_ROWS * SYSTEM_FONT_GLYPH_HEIGHT; + +// #endregion + +// #region Atlas Builder + +/** + * Expands bit-pattern glyph data into a flat palette-indexed pixel array. + * + * Each set bit becomes palette index `1` (foreground); each clear bit becomes + * index `0` (transparent). The layout is a grid of {@link ATLAS_COLUMNS} + * columns by {@link ATLAS_ROWS} rows. + * + * @returns Uint8Array of `ATLAS_WIDTH * ATLAS_HEIGHT` palette indices. + */ +function buildAtlasPixels(): Uint8Array { + const pixels = new Uint8Array(ATLAS_WIDTH * ATLAS_HEIGHT) as Uint8Array; + + for (let i = 0; i < SYSTEM_FONT_GLYPH_COUNT; i++) { + const col = i % ATLAS_COLUMNS; + const row = Math.floor(i / ATLAS_COLUMNS); + const baseX = col * SYSTEM_FONT_GLYPH_WIDTH; + const baseY = row * SYSTEM_FONT_GLYPH_HEIGHT; + const bitmapOffset = i * SYSTEM_FONT_BYTES_PER_GLYPH; + + for (let y = 0; y < SYSTEM_FONT_GLYPH_HEIGHT; y++) { + const rowByte = SYSTEM_FONT_BITMAPS[bitmapOffset + y] ?? 0; + + for (let x = 0; x < SYSTEM_FONT_GLYPH_WIDTH; x++) { + // Bit 7 is leftmost pixel. + const bit = (rowByte >> (7 - x)) & 1; + const px = baseX + x; + const py = baseY + y; + + pixels[py * ATLAS_WIDTH + px] = bit; + } + } + } + + return pixels; +} + +/** + * Builds the glyph map for all printable ASCII characters. + * + * @returns Map of single-character strings to their {@link Glyph} metadata. + */ +function buildGlyphMap(): Map { + const glyphs = new Map(); + + for (let i = 0; i < SYSTEM_FONT_GLYPH_COUNT; i++) { + const charCode = SYSTEM_FONT_FIRST_CHAR + i; + const col = i % ATLAS_COLUMNS; + const row = Math.floor(i / ATLAS_COLUMNS); + + const glyph: Glyph = { + rect: new Rect2i( + col * SYSTEM_FONT_GLYPH_WIDTH, + row * SYSTEM_FONT_GLYPH_HEIGHT, + SYSTEM_FONT_GLYPH_WIDTH, + SYSTEM_FONT_GLYPH_HEIGHT, + ), + offsetX: 0, + offsetY: 0, + advance: SYSTEM_FONT_GLYPH_WIDTH, + }; + + glyphs.set(String.fromCharCode(charCode), glyph); + } + + return glyphs; +} + +// #endregion + +// #region Public API + +/** + * Creates the built-in 6x14 system font. + * + * The returned {@link BitmapFont} is immediately ready for rendering through + * the sprite pipeline. Its texture stores only palette indices `0` + * (transparent) and `1` (foreground), so it works with any palette by using + * `paletteOffset = desiredPaletteIndex - 1`. + * + * @returns A fully constructed BitmapFont with the built-in 6x14 glyphs. + */ +export function createSystemFont(): BitmapFont { + const pixels = buildAtlasPixels(); + const spriteSheet = SpriteSheet.fromIndexedPixels(ATLAS_WIDTH, ATLAS_HEIGHT, pixels); + const glyphs = buildGlyphMap(); + + return BitmapFont.createFromGlyphs( + spriteSheet, + glyphs, + 'System', + SYSTEM_FONT_GLYPH_HEIGHT, + SYSTEM_FONT_GLYPH_HEIGHT, + SYSTEM_FONT_GLYPH_HEIGHT, + ); +} + +// #endregion diff --git a/src/assets/fonts/systemFontData.ts b/src/assets/fonts/systemFontData.ts new file mode 100644 index 0000000..4e24c95 --- /dev/null +++ b/src/assets/fonts/systemFontData.ts @@ -0,0 +1,231 @@ +/** + * Built-in system font bitmap data. + * + * Covers printable ASCII characters 32-126 (95 glyphs). Each glyph is + * 14 bytes, one byte per row (top to bottom). Bit 7 is the leftmost pixel; + * only the top 6 bits (bits 7-2) are used for the 6-pixel glyph width. + * A set bit means palette index 1 (opaque foreground); a clear bit means + * index 0 (transparent). + * + * The data is a flat array of `95 * 14 = 1330` bytes. Access glyph for + * character code `c` at offset `(c - 32) * 14`. + * + * This file is auto-generated by `pnpm system-font:convert` from the PNG + * atlas at `assets/system-font.png`. To edit the font visually, modify the + * PNG in a pixel editor and re-run the conversion script. To export the + * current data back to PNG, use `pnpm system-font:export`. + * + * This font data is in the public domain. + */ + +// prettier-ignore +export const SYSTEM_FONT_BITMAPS: readonly number[] = [ + // Space (32) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // ! (33) + 0x00, 0x00, 0x00, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, + // " (34) + 0x00, 0x00, 0x00, 0x50, 0x50, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // # (35) + 0x00, 0x00, 0x00, 0x00, 0x50, 0xf8, 0x50, 0xf8, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, + // $ (36) + 0x00, 0x00, 0x20, 0x70, 0xa8, 0xa0, 0x70, 0x28, 0xa8, 0x70, 0x20, 0x00, 0x00, 0x00, + // % (37) + 0x00, 0x00, 0x00, 0x68, 0xa8, 0xd0, 0x20, 0x58, 0xa8, 0xb0, 0x00, 0x00, 0x00, 0x00, + // & (38) + 0x00, 0x00, 0x00, 0x38, 0x6c, 0x38, 0x74, 0xdc, 0xcc, 0x74, 0x00, 0x00, 0x00, 0x00, + // ' (39) + 0x00, 0x00, 0x00, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // ( (40) + 0x00, 0x00, 0x10, 0x20, 0x20, 0x40, 0x40, 0x40, 0x20, 0x20, 0x10, 0x00, 0x00, 0x00, + // ) (41) + 0x00, 0x00, 0x40, 0x20, 0x20, 0x10, 0x10, 0x10, 0x20, 0x20, 0x40, 0x00, 0x00, 0x00, + // * (42) + 0x00, 0x00, 0x00, 0x00, 0x88, 0x50, 0x20, 0x50, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, + // + (43) + 0x00, 0x00, 0x00, 0x00, 0x20, 0x20, 0xf8, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, + // , (44) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x60, 0x20, 0x40, 0x00, 0x00, + // - (45) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // . (46) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x60, 0x00, 0x00, 0x00, 0x00, + // / (47) + 0x00, 0x00, 0x08, 0x08, 0x10, 0x10, 0x20, 0x20, 0x40, 0x40, 0x80, 0x80, 0x00, 0x00, + // 0 (48) + 0x00, 0x00, 0x00, 0x70, 0x88, 0x98, 0xa8, 0xc8, 0x88, 0x70, 0x00, 0x00, 0x00, 0x00, + // 1 (49) + 0x00, 0x00, 0x00, 0x20, 0x60, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, 0x00, 0x00, 0x00, + // 2 (50) + 0x00, 0x00, 0x00, 0x70, 0x88, 0x08, 0x10, 0x20, 0x40, 0xf8, 0x00, 0x00, 0x00, 0x00, + // 3 (51) + 0x00, 0x00, 0x00, 0x70, 0x88, 0x08, 0x30, 0x08, 0x88, 0x70, 0x00, 0x00, 0x00, 0x00, + // 4 (52) + 0x00, 0x00, 0x00, 0x10, 0x30, 0x50, 0x90, 0xf8, 0x10, 0x10, 0x00, 0x00, 0x00, 0x00, + // 5 (53) + 0x00, 0x00, 0x00, 0xf8, 0x80, 0xf0, 0x08, 0x08, 0x88, 0x70, 0x00, 0x00, 0x00, 0x00, + // 6 (54) + 0x00, 0x00, 0x00, 0x70, 0x80, 0xf0, 0x88, 0x88, 0x88, 0x70, 0x00, 0x00, 0x00, 0x00, + // 7 (55) + 0x00, 0x00, 0x00, 0xf8, 0x08, 0x08, 0x10, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, + // 8 (56) + 0x00, 0x00, 0x00, 0x70, 0x88, 0x88, 0x70, 0x88, 0x88, 0x70, 0x00, 0x00, 0x00, 0x00, + // 9 (57) + 0x00, 0x00, 0x00, 0x70, 0x88, 0x88, 0x88, 0x78, 0x08, 0x70, 0x00, 0x00, 0x00, 0x00, + // : (58) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x60, 0x00, 0x60, 0x60, 0x00, 0x00, 0x00, 0x00, + // ; (59) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x60, 0x00, 0x60, 0x60, 0x20, 0x40, 0x00, 0x00, + // < (60) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x20, 0x40, 0x20, 0x10, 0x00, 0x00, 0x00, 0x00, + // = (61) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, + // > (62) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x20, 0x10, 0x20, 0x40, 0x00, 0x00, 0x00, 0x00, + // ? (63) + 0x00, 0x00, 0x00, 0x70, 0x88, 0x08, 0x10, 0x20, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, + // @ (64) + 0x00, 0x00, 0x00, 0x70, 0x88, 0xb8, 0xa8, 0xb8, 0x80, 0x78, 0x00, 0x00, 0x00, 0x00, + // A (65) + 0x00, 0x00, 0x00, 0x70, 0x88, 0x88, 0xf8, 0x88, 0x88, 0x88, 0x00, 0x00, 0x00, 0x00, + // B (66) + 0x00, 0x00, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0x88, 0x88, 0xf0, 0x00, 0x00, 0x00, 0x00, + // C (67) + 0x00, 0x00, 0x00, 0x70, 0x88, 0x80, 0x80, 0x80, 0x88, 0x70, 0x00, 0x00, 0x00, 0x00, + // D (68) + 0x00, 0x00, 0x00, 0xf0, 0x88, 0x88, 0x88, 0x88, 0x88, 0xf0, 0x00, 0x00, 0x00, 0x00, + // E (69) + 0x00, 0x00, 0x00, 0xf8, 0x80, 0x80, 0xf0, 0x80, 0x80, 0xf8, 0x00, 0x00, 0x00, 0x00, + // F (70) + 0x00, 0x00, 0x00, 0xf8, 0x80, 0x80, 0xf0, 0x80, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, + // G (71) + 0x00, 0x00, 0x00, 0x70, 0x88, 0x80, 0x98, 0x88, 0x88, 0x70, 0x00, 0x00, 0x00, 0x00, + // H (72) + 0x00, 0x00, 0x00, 0x88, 0x88, 0x88, 0xf8, 0x88, 0x88, 0x88, 0x00, 0x00, 0x00, 0x00, + // I (73) + 0x00, 0x00, 0x00, 0x70, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, 0x00, 0x00, 0x00, + // J (74) + 0x00, 0x00, 0x00, 0x08, 0x08, 0x08, 0x08, 0x88, 0x88, 0x70, 0x00, 0x00, 0x00, 0x00, + // K (75) + 0x00, 0x00, 0x00, 0x88, 0x90, 0xa0, 0xc0, 0xa0, 0x90, 0x88, 0x00, 0x00, 0x00, 0x00, + // L (76) + 0x00, 0x00, 0x00, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xf8, 0x00, 0x00, 0x00, 0x00, + // M (77) + 0x00, 0x00, 0x00, 0x88, 0xd8, 0xa8, 0x88, 0x88, 0x88, 0x88, 0x00, 0x00, 0x00, 0x00, + // N (78) + 0x00, 0x00, 0x00, 0x88, 0x88, 0xc8, 0xa8, 0x98, 0x88, 0x88, 0x00, 0x00, 0x00, 0x00, + // O (79) + 0x00, 0x00, 0x00, 0x70, 0x88, 0x88, 0x88, 0x88, 0x88, 0x70, 0x00, 0x00, 0x00, 0x00, + // P (80) + 0x00, 0x00, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0x80, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, + // Q (81) + 0x00, 0x00, 0x00, 0x70, 0x88, 0x88, 0x88, 0x88, 0x88, 0x70, 0x10, 0x08, 0x00, 0x00, + // R (82) + 0x00, 0x00, 0x00, 0xf0, 0x88, 0x88, 0xf0, 0x88, 0x88, 0x88, 0x00, 0x00, 0x00, 0x00, + // S (83) + 0x00, 0x00, 0x00, 0x70, 0x88, 0x80, 0x70, 0x08, 0x88, 0x70, 0x00, 0x00, 0x00, 0x00, + // T (84) + 0x00, 0x00, 0x00, 0xf8, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, + // U (85) + 0x00, 0x00, 0x00, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x70, 0x00, 0x00, 0x00, 0x00, + // V (86) + 0x00, 0x00, 0x00, 0x88, 0x88, 0x88, 0x50, 0x50, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, + // W (87) + 0x00, 0x00, 0x00, 0x88, 0x88, 0x88, 0x88, 0xa8, 0xd8, 0x88, 0x00, 0x00, 0x00, 0x00, + // X (88) + 0x00, 0x00, 0x00, 0x88, 0x88, 0x50, 0x20, 0x50, 0x88, 0x88, 0x00, 0x00, 0x00, 0x00, + // Y (89) + 0x00, 0x00, 0x00, 0x88, 0x88, 0x50, 0x20, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, + // Z (90) + 0x00, 0x00, 0x00, 0xf8, 0x08, 0x10, 0x20, 0x40, 0x80, 0xf8, 0x00, 0x00, 0x00, 0x00, + // [ (91) + 0x00, 0x00, 0x70, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x70, 0x00, 0x00, 0x00, + // \ (92) + 0x00, 0x00, 0x80, 0x80, 0x40, 0x40, 0x20, 0x20, 0x10, 0x10, 0x08, 0x08, 0x00, 0x00, + // ] (93) + 0x00, 0x00, 0x70, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x70, 0x00, 0x00, 0x00, + // ^ (94) + 0x00, 0x00, 0x00, 0x20, 0x50, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // _ (95) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, + // ` (96) + 0x00, 0x00, 0x00, 0x20, 0x20, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // a (97) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x70, 0x08, 0x78, 0x88, 0x78, 0x00, 0x00, 0x00, 0x00, + // b (98) + 0x00, 0x00, 0x00, 0x80, 0x80, 0xf0, 0x88, 0x88, 0x88, 0xf0, 0x00, 0x00, 0x00, 0x00, + // c (99) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x70, 0x88, 0x80, 0x88, 0x70, 0x00, 0x00, 0x00, 0x00, + // d (100) + 0x00, 0x00, 0x00, 0x08, 0x08, 0x78, 0x88, 0x88, 0x88, 0x78, 0x00, 0x00, 0x00, 0x00, + // e (101) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x70, 0x88, 0xf8, 0x80, 0x70, 0x00, 0x00, 0x00, 0x00, + // f (102) + 0x00, 0x00, 0x00, 0x30, 0x40, 0xf0, 0x40, 0x40, 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, + // g (103) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x70, 0x88, 0x88, 0x88, 0x78, 0x08, 0x70, 0x00, 0x00, + // h (104) + 0x00, 0x00, 0x00, 0x80, 0x80, 0xf0, 0x88, 0x88, 0x88, 0x88, 0x00, 0x00, 0x00, 0x00, + // i (105) + 0x00, 0x00, 0x00, 0x20, 0x00, 0x60, 0x20, 0x20, 0x20, 0x70, 0x00, 0x00, 0x00, 0x00, + // j (106) + 0x00, 0x00, 0x00, 0x20, 0x00, 0x60, 0x20, 0x20, 0x20, 0x20, 0x20, 0xc0, 0x00, 0x00, + // k (107) + 0x00, 0x00, 0x00, 0x80, 0x80, 0x90, 0xa0, 0xe0, 0x90, 0x90, 0x00, 0x00, 0x00, 0x00, + // l (108) + 0x00, 0x00, 0x00, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x10, 0x00, 0x00, 0x00, 0x00, + // m (109) + 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xa8, 0xa8, 0xa8, 0xa8, 0x00, 0x00, 0x00, 0x00, + // n (110) + 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x88, 0x88, 0x88, 0x88, 0x00, 0x00, 0x00, 0x00, + // o (111) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x70, 0x88, 0x88, 0x88, 0x70, 0x00, 0x00, 0x00, 0x00, + // p (112) + 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x88, 0x88, 0x88, 0xf0, 0x80, 0x80, 0x00, 0x00, + // q (113) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0x88, 0x88, 0x88, 0x78, 0x08, 0x08, 0x00, 0x00, + // r (114) + 0x00, 0x00, 0x00, 0x00, 0x00, 0xb0, 0xc8, 0x80, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, + // s (115) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0x80, 0x70, 0x08, 0xf0, 0x00, 0x00, 0x00, 0x00, + // t (116) + 0x00, 0x00, 0x00, 0x40, 0x40, 0xf0, 0x40, 0x40, 0x40, 0x30, 0x00, 0x00, 0x00, 0x00, + // u (117) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x88, 0x88, 0x88, 0x88, 0x78, 0x00, 0x00, 0x00, 0x00, + // v (118) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x88, 0x88, 0x50, 0x50, 0x20, 0x00, 0x00, 0x00, 0x00, + // w (119) + 0x00, 0x00, 0x00, 0x00, 0x00, 0xa8, 0xa8, 0xa8, 0xa8, 0x50, 0x00, 0x00, 0x00, 0x00, + // x (120) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x88, 0x50, 0x20, 0x50, 0x88, 0x00, 0x00, 0x00, 0x00, + // y (121) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x88, 0x88, 0x88, 0x88, 0x78, 0x08, 0x70, 0x00, 0x00, + // z (122) + 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x10, 0x20, 0x40, 0xf8, 0x00, 0x00, 0x00, 0x00, + // { (123) + 0x00, 0x00, 0x10, 0x20, 0x20, 0x20, 0x40, 0x20, 0x20, 0x20, 0x10, 0x00, 0x00, 0x00, + // | (124) + 0x00, 0x00, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, + // } (125) + 0x00, 0x00, 0x40, 0x20, 0x20, 0x20, 0x10, 0x20, 0x20, 0x20, 0x40, 0x00, 0x00, 0x00, + // ~ (126) + 0x00, 0x00, 0x40, 0xa8, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; + +/** First character code in the bitmap array. */ +export const SYSTEM_FONT_FIRST_CHAR = 32; + +/** Last character code in the bitmap array (inclusive). */ +export const SYSTEM_FONT_LAST_CHAR = 126; + +/** Number of glyphs in the system font. */ +export const SYSTEM_FONT_GLYPH_COUNT = SYSTEM_FONT_LAST_CHAR - SYSTEM_FONT_FIRST_CHAR + 1; + +/** Width of each glyph in pixels. */ +export const SYSTEM_FONT_GLYPH_WIDTH = 6; + +/** Height of each glyph in pixels. */ +export const SYSTEM_FONT_GLYPH_HEIGHT = 14; + +/** Number of bytes per glyph (one byte per row). */ +export const SYSTEM_FONT_BYTES_PER_GLYPH = 14; diff --git a/src/core/BTAPI.test.ts b/src/core/BTAPI.test.ts index e44625e..860ea5a 100644 --- a/src/core/BTAPI.test.ts +++ b/src/core/BTAPI.test.ts @@ -195,8 +195,8 @@ describe('BTAPI', () => { expect(() => BTAPI.instance.drawRectFill(new Rect2i(0, 0, 10, 10), 5)).not.toThrow(); }); - it('drawText should not throw before init', () => { - expect(() => BTAPI.instance.drawText(new Vector2i(0, 0), 8, 'test')).not.toThrow(); + it('drawSystemText should not throw before init', () => { + expect(() => BTAPI.instance.drawSystemText(new Vector2i(0, 0), 8, 'test')).not.toThrow(); }); it('drawSprite should not throw before init', () => { diff --git a/src/core/BTAPI.ts b/src/core/BTAPI.ts index 33932cf..2fdcf6c 100644 --- a/src/core/BTAPI.ts +++ b/src/core/BTAPI.ts @@ -9,6 +9,7 @@ import { paletteSwap, } from '../assets/PaletteEffect'; import type { SpriteSheet } from '../assets/SpriteSheet'; +import { createSystemFont } from '../assets/SystemFont'; import { Renderer } from '../render/Renderer'; import type { Color32 } from '../utils/Color32'; import type { EasingFunction } from '../utils/Easing'; @@ -85,6 +86,9 @@ export class BTAPI { /** 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; + // TODO: Additional subsystems for future implementation: // InputManager, AudioManager, AssetManager @@ -189,6 +193,9 @@ export class BTAPI { console.log('[BT] Renderer initialized'); + // Create the built-in system font (synchronous, no GPU needed yet). + this.systemFont = createSystemFont(); + // TODO: Initialize input, audio, etc. // Initialize the demo. @@ -413,16 +420,35 @@ export class BTAPI { } /** - * Draws placeholder text (simple rectangle blocks). - * For proper text rendering, use drawBitmapText() instead. + * 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. + * @param paletteIndex - Palette color index for the text. * @param text - String to display. */ - public drawText(pos: Vector2i, paletteIndex: number, text: string): void { + public drawSystemText(pos: Vector2i, paletteIndex: number, text: string): void { this.assertPaletteIndex(paletteIndex); - this.renderer?.drawText(pos, paletteIndex, text); + + if (this.systemFont) { + // Offset math: font stores foreground as index 1. + // Shader computes 1 + (paletteIndex - 1) = paletteIndex. + const offset = paletteIndex > 0 ? paletteIndex - 1 : 0; + + this.renderer?.drawBitmapText(this.systemFont, pos, text, offset); + } + } + + /** + * Returns the built-in system font, or null if not yet initialized. + * + * @returns The system BitmapFont instance. + */ + public getSystemFont(): BitmapFont | null { + return this.systemFont; } // #endregion diff --git a/src/render/PrimitivePipeline.test.ts b/src/render/PrimitivePipeline.test.ts index 7404b92..cd6ceac 100644 --- a/src/render/PrimitivePipeline.test.ts +++ b/src/render/PrimitivePipeline.test.ts @@ -140,15 +140,6 @@ describe('pre-initialization safety', () => { pipeline.clearRect(rect, 1); }).not.toThrow(); }); - - it('drawText() does not throw before initialize', () => { - const pipeline = new PrimitivePipeline(); - const pos = new Vector2i(10, 10); - - expect(() => { - pipeline.drawText(pos, 8, 'Hello'); - }).not.toThrow(); - }); }); // #endregion @@ -417,25 +408,6 @@ describe('vertex count verification', () => { expect(totalVertices).toBe(24); }); - it('drawText produces six vertices per character', () => { - pipeline.reset(); - - pipeline.drawText(new Vector2i(0, 0), 8, 'Hi'); - - let totalVertices = 0; - - const renderPass = { - ...createMockRenderPassEncoder(), - draw: (vertexCount: number) => { - totalVertices += vertexCount; - }, - } as unknown as GPURenderPassEncoder; - - pipeline.encodePass(renderPass); - - expect(totalVertices).toBe(2 * 6); // 2 characters * 6 vertices each - }); - it('buffer overflow triggers console.warn and does not crash', () => { pipeline.reset(); diff --git a/src/render/PrimitivePipeline.ts b/src/render/PrimitivePipeline.ts index eda91a8..e43c9fe 100644 --- a/src/render/PrimitivePipeline.ts +++ b/src/render/PrimitivePipeline.ts @@ -153,35 +153,6 @@ export class PrimitivePipeline { this.addVertex(x0, y1, paletteIndex); } - /** - * Draws placeholder text as colored blocks. - * Each character is rendered as a small filled rectangle. - * - * @param pos - Text position (top-left corner). - * @param paletteIndex - Palette color index. - * @param text - String to display. - */ - drawText(pos: Vector2i, paletteIndex: number, text: string): void { - const charWidth = 6; - const charHeight = 8; - - for (let i = 0; i < text.length; i++) { - const x0 = pos.x + i * charWidth; - const x1 = x0 + charWidth - 1; - const y0 = pos.y; - const y1 = y0 + charHeight; - - // Draw directly without allocating Rect2i. - this.addVertex(x0, y0, paletteIndex); - this.addVertex(x1, y0, paletteIndex); - this.addVertex(x0, y1, paletteIndex); - - this.addVertex(x1, y0, paletteIndex); - this.addVertex(x1, y1, paletteIndex); - this.addVertex(x0, y1, paletteIndex); - } - } - /** * Draws a single pixel as a 1x1 filled rectangle. * diff --git a/src/render/Renderer.test.ts b/src/render/Renderer.test.ts index e0221b4..68dedfb 100644 --- a/src/render/Renderer.test.ts +++ b/src/render/Renderer.test.ts @@ -317,16 +317,6 @@ describe('with initialized renderer', () => { renderer.endFrame(); }); - it('drawText delegates without throwing', () => { - renderer.beginFrame(); - - expect(() => { - renderer.drawText(new Vector2i(10, 10), 8, 'Test'); - }).not.toThrow(); - - renderer.endFrame(); - }); - it('setClearColor works within a frame', () => { renderer.beginFrame(); diff --git a/src/render/Renderer.ts b/src/render/Renderer.ts index 10ab45f..fa7ec44 100644 --- a/src/render/Renderer.ts +++ b/src/render/Renderer.ts @@ -300,18 +300,6 @@ export class Renderer { this.primitives.drawRectFill(rect, paletteIndex); } - /** - * Draws placeholder text as colored blocks. - * Each character is rendered as a small filled rectangle. - * - * @param pos - Text position (top-left corner). - * @param paletteIndex - Palette color index. - * @param text - String to display. - */ - drawText(pos: Vector2i, paletteIndex: number, text: string): void { - this.primitives.drawText(pos, paletteIndex, text); - } - /** * Draws a single pixel as a 1x1 filled rectangle. *