asciitorium
Version:
an ASCII CLUI framework
455 lines (454 loc) • 17.7 kB
JavaScript
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;
}