UNPKG

asciitorium

Version:
455 lines (454 loc) 17.7 kB
import { Component } from '../core/Component.js'; import { requestRender } from '../core/RenderScheduler.js'; import { State } from '../core/State.js'; import { AssetManager, } from '../core/AssetManager.js'; export class Art extends Component { constructor(options) { let actualContent = options.content; const borderPadding = options.border ? 2 : 0; const isLoadingSprite = !!options.sprite; // Prepare content and dimensions before super() call let parsedFrames = []; let parsedLoop = false; let spriteTransparent; let calculatedWidth; let calculatedHeight; if (!isLoadingSprite) { // Handle direct content/children (non-sprite) if (!actualContent && options.children) { const children = Array.isArray(options.children) ? options.children : [options.children]; if (children.length > 0) { actualContent = children[0]; } } if (!actualContent) { throw new Error('AsciiArt component requires either sprite, content prop, or children'); } // Handle State<string> or string content - determine initial value let contentValue; if (actualContent instanceof State) { contentValue = actualContent.value; } else { contentValue = actualContent; } // Parse the content directly and measure dimensions only if not provided const parsed = parseSprite(contentValue); const { maxW, maxH } = measureFrames(parsed.frames); parsedFrames = parsed.frames; parsedLoop = parsed.defaults.loop || false; // Store sprite-specific transparent character for later assignment spriteTransparent = parsed.defaults.transparent; calculatedWidth = Math.max(1, maxW + borderPadding); calculatedHeight = Math.max(1, maxH + borderPadding); } else { // For sprite loading, use placeholder parsedFrames = [ { lines: [['L', 'o', 'a', 'd', 'i', 'n', 'g', '.', '.', '.']], meta: { duration: 0 }, }, ]; parsedLoop = false; calculatedWidth = 12; // "Loading..." length + border calculatedHeight = 1 + borderPadding; } // Call super() with calculated or provided dimensions const { children, content, sprite, ...componentProps } = options; super({ ...componentProps, width: options.width ?? options.style?.width ?? calculatedWidth, height: options.height ?? options.style?.height ?? calculatedHeight, }); this.frames = []; this.frameIndex = 0; this.loop = false; this.timer = null; this.isLoading = false; this.isDestroyed = false; // Track if component has been destroyed // Now we can safely assign to this if (spriteTransparent !== undefined) { this.spriteTransparentChar = spriteTransparent; } // Set initial state this.frames = parsedFrames; this.loop = parsedLoop; if (isLoadingSprite && options.sprite) { // Set loading state this.sprite = options.sprite; this.isLoading = true; // Start async loading using AssetManager AssetManager.getSprite(options.sprite) .then((spriteAsset) => { if (this.isDestroyed) return; this.isLoading = false; this.loadError = undefined; // Calculate dimensions from sprite frames const { maxW, maxH } = measureFrames(spriteAsset.frames); // Wrap in Asset format for updateContentFromAsset const asset = { kind: 'sprite', width: maxW, height: maxH, data: spriteAsset, }; this.updateContentFromAsset(asset); requestRender(); this.forceRenderIfNeeded(); }) .catch((error) => { if (this.isDestroyed) return; this.isLoading = false; this.loadError = error.message || 'Failed to load ASCII art'; // Don't call updateContent with error text - just set simple error frame const errorText = `Error: ${this.loadError}`; this.frames = [{ lines: [[...errorText]], meta: { duration: 0 } }]; // Update dimensions to fit error text const borderPadding = this.border ? 2 : 0; this.originalWidth = errorText.length + borderPadding; this.originalHeight = 1 + borderPadding; this.width = errorText.length + borderPadding; this.height = 1 + borderPadding; requestRender(); this.forceRenderIfNeeded(); }); } else { // Set up state subscription for reactive content if (actualContent instanceof State) { this.contentState = actualContent; this.bind(this.contentState, (newValue) => { this.updateContent(newValue); }); } // Start animation if we have multiple frames if (this.frames.length > 1) { this.startAnimation(); } } } startAnimation() { // Kick the very first frame sound (if any) this.maybePlaySound(this.frames[this.frameIndex]?.meta.sound); this.scheduleNext(); } scheduleNext() { // Stop scheduling if component has been destroyed if (this.isDestroyed) return; const current = this.frames[this.frameIndex]; const dur = Math.max(0, current?.meta.duration ?? 0); // Safety: if duration is 0 or missing, render next microtask to avoid tight loops const delay = Number.isFinite(dur) && dur > 0 ? dur : 0; this.clearTimer(); this.timer = setTimeout(() => { this.advanceFrame(); }, delay); } advanceFrame() { // Stop animation if component has been destroyed if (this.isDestroyed) return; if (this.frames.length <= 1) return; this.frameIndex++; if (this.frameIndex >= this.frames.length) { if (this.loop) { this.frameIndex = 0; } else { this.frameIndex = this.frames.length - 1; // stick on last this.clearTimer(); requestRender(); return; } } // Sound for the new frame this.maybePlaySound(this.frames[this.frameIndex]?.meta.sound); requestRender(); this.scheduleNext(); } maybePlaySound(id) { if (!id) return; // Sound system not implemented yet - just log for now } clearTimer() { if (this.timer) { clearTimeout(this.timer); this.timer = null; } } updateContent(newContent) { if (this.isDestroyed) return; // Stop current animation this.clearTimer(); // Re-parse the new content const parsed = parseSprite(newContent); const { maxW, maxH } = measureFrames(parsed.frames); const newWidth = maxW + (this.border ? 2 : 0); const newHeight = maxH + (this.border ? 2 : 0); // Update component dimensions this.originalHeight = newHeight; this.originalWidth = newWidth; this.width = newWidth; this.height = newHeight; // Update frames and reset animation state this.frames = parsed.frames; this.loop = parsed.defaults.loop || false; this.frameIndex = 0; // Restart animation if needed if (this.frames.length > 1) { this.startAnimation(); } // Request a re-render requestRender(); } updateContentFromAsset(asset) { if (this.isDestroyed) return; // Stop current animation this.clearTimer(); // Use AssetManager's pre-calculated dimensions (includes all frames) const spriteAsset = asset.data; const newWidth = asset.width + (this.border ? 2 : 0); const newHeight = asset.height + (this.border ? 2 : 0); // Update component dimensions this.originalHeight = newHeight; this.originalWidth = newWidth; this.width = newWidth; this.height = newHeight; // Update frames and reset animation state this.frames = spriteAsset.frames; this.loop = spriteAsset.defaults.loop || false; // Store sprite-specific transparent character if (spriteAsset.defaults.transparent !== undefined) { this.spriteTransparentChar = spriteAsset.defaults.transparent; } this.frameIndex = 0; // Restart animation if needed if (this.frames.length > 1) { this.startAnimation(); } // Request a re-render requestRender(); } extractSpriteName(src) { // Handle old path format: "./art/sprites/player.art" -> "player" if (src.includes('/sprites/')) { const parts = src.split('/sprites/'); if (parts.length > 1) { const spritePart = parts[1]; const spriteName = spritePart.replace('.art', ''); return spriteName; } } // Handle direct asset name: "player" -> "player" return src; } forceRenderIfNeeded() { // Use the base class method for focus refresh (which also triggers render) this.notifyAppOfFocusRefresh(); } destroy() { this.isDestroyed = true; this.clearTimer(); super.destroy(); // Note: Component.destroy() automatically handles state unsubscriptions } draw() { try { const buffer = super.draw(); // Defensive check: ensure buffer was created successfully if (!buffer || buffer.length === 0) { return buffer; } const xOffset = this.border ? 1 : 0; const yOffset = this.border ? 1 : 0; const innerWidth = this.width - (this.border ? 2 : 0); const innerHeight = this.height - (this.border ? 2 : 0); // Use sprite-specific transparent char if defined, otherwise use component's default const transparentChar = this.spriteTransparentChar ?? this.transparentChar; // Sprite/content rendering mode const frame = this.frames[this.frameIndex]; if (!frame) return buffer; const lines = frame.lines; for (let y = 0; y < Math.min(lines.length, innerHeight); y++) { const line = lines[y]; const bufferY = y + yOffset; // Defensive check: ensure buffer row exists (race condition protection) if (bufferY >= buffer.length) break; for (let x = 0; x < Math.min(line.length, innerWidth); x++) { const bufferX = x + xOffset; // Defensive check: ensure buffer column exists (race condition protection) if (!buffer[bufferY] || bufferX >= buffer[bufferY].length) break; const char = line[x]; // If character matches transparent char, use framework's transparent char // Otherwise, render the actual character if (char === transparentChar) { buffer[bufferY][bufferX] = this.transparentChar; } else { buffer[bufferY][bufferX] = char; } } } this.buffer = buffer; return buffer; } catch (error) { // If any error occurs during draw, return empty buffer to prevent crash console.error('Art component draw() error:', error); return super.draw(); // Return basic empty buffer } } } /* ========================= Parsing & utilities ========================= */ /** * parseSprite — supports: * - Defaults: first non-empty line starting with § {json} * - Frames: blocks terminated by ¶ {json} (meta for previous block) * - Stills: if no §/¶ present, entire input is a single frame using fallbacks * - Graceful JSON error handling (collects errors; uses fallbacks) * - Trims CRLF; preserves leading/trailing spaces inside art lines */ function parseSprite(text) { const errors = []; const lines = text.replace(/\r\n?/g, '\n').split('\n'); let defaults = {}; let sawAnyArt = false; let firstNonEmptySeen = false; // Buffers let currentBlock = []; const rawFrames = []; const flush = (meta) => { // Allow empty frames (rare), but usually there is content rawFrames.push({ art: currentBlock.slice(), meta }); currentBlock = []; }; const tryParseJSON = (s, where) => { try { return JSON.parse(s); } catch (e) { errors.push(`JSON parse error ${where}: ${e?.message ?? String(e)}`); return {}; } }; // Scan lines for (let i = 0; i < lines.length; i++) { const raw = lines[i]; const trimmedStart = raw.trimStart(); // First non-empty line special-cases defaults if (!firstNonEmptySeen && trimmedStart.length > 0) { firstNonEmptySeen = true; if (trimmedStart.startsWith('§')) { const payload = raw.slice(raw.indexOf('§') + 1).trim(); if (payload) { const d = tryParseJSON(payload, `in defaults at line ${i + 1}`); // Only pick the keys we support for now defaults = { duration: asNum(d?.duration), loop: asBool(d?.loop), transparent: asString(d?.transparent), }; } // continue to next line; defaults line itself is not art continue; } } // Frame separator if (trimmedStart.startsWith('¶')) { const payload = raw.slice(raw.indexOf('¶') + 1).trim(); const metaRaw = payload ? tryParseJSON(payload, `in frame meta at line ${i + 1}`) : {}; const meta = { duration: asNum(metaRaw?.duration), sound: typeof metaRaw?.sound === 'string' ? metaRaw.sound : undefined, }; flush(meta); sawAnyArt = true; continue; } // Otherwise, this is art content (preserve exactly; only strip trailing \r earlier) currentBlock.push(raw); if (raw.length > 0) sawAnyArt = true; } // If file didn't end with a separator, flush the trailing block if (currentBlock.length) { flush({}); } // If we never saw § or ¶ and we have art, treat entire input as a still const usedSpriteFormat = lines.some((l) => l.trimStart().startsWith('§') || l.trimStart().startsWith('¶')); if (!usedSpriteFormat && sawAnyArt) { const still = { lines: normalizeBlock(lines), meta: { duration: 0 }, // irrelevant; there’s only one frame }; return { defaults: { duration: 0, loop: false }, frames: [still], errors, }; } const frames = rawFrames.map(({ art, meta }) => { const merged = { duration: meta?.duration ?? defaults.duration ?? 100, sound: meta?.sound, }; return { lines: normalizeBlock(art), meta: merged }; }); // Edge case: if no frames collected (e.g., empty file), produce a single blank frame if (frames.length === 0) { frames.push({ lines: [[]], meta: { duration: defaults.duration ?? 100 } }); } return { defaults, frames, errors }; } /** Normalize a block of lines into a 2D char array (ragged-right preserved) */ function normalizeBlock(blockLines) { // Drop a single leading empty line if present (authoring convenience) const lines = blockLines.slice(); if (lines.length && lines[0] === '') { lines.shift(); } const result = lines.map((line) => [...line]); return result; } /** Measure max width/height across frames to size component surface */ function measureFrames(frames) { let maxW = 1; let maxH = 1; for (const f of frames) { maxH = Math.max(maxH, f.lines.length || 1); for (const ln of f.lines) { maxW = Math.max(maxW, ln.length || 0); } } return { maxW, maxH }; } /* Small helpers */ function asNum(v) { return typeof v === 'number' && Number.isFinite(v) ? v : undefined; } function asBool(v) { return typeof v === 'boolean' ? v : false; } function asString(v) { return typeof v === 'string' && v.length === 1 ? v : undefined; }