UNPKG

asciitorium

Version:
473 lines (472 loc) 19.3 kB
import { loadArt } from './environment.js'; export class AssetManager { static async getMap(name) { // Check cache first - return same Promise every time if (this.mapCache.has(name)) { return this.mapCache.get(name); } // Start async load and cache the promise const promise = this.loadMapAsset(name); this.mapCache.set(name, promise); return promise; } static async getMaterial(name) { // Check cache first - return same Promise every time if (this.materialCache.has(name)) { return this.materialCache.get(name); } // Start async load and cache the promise const promise = this.loadMaterialAsset(name); this.materialCache.set(name, promise); return promise; } static async getSprite(name) { // Check cache first - return same Promise every time if (this.spriteCache.has(name)) { return this.spriteCache.get(name); } // Start async load and cache the promise const promise = this.loadSpriteAsset(name); this.spriteCache.set(name, promise); return promise; } static async getFont(name) { // Check cache first - return same Promise every time if (this.fontCache.has(name)) { return this.fontCache.get(name); } // Start async load and cache the promise const promise = this.loadFontAsset(name); this.fontCache.set(name, promise); return promise; } // Dimension calculation methods static calculateMapWidth(asset) { return Math.max(...asset.mapData.map((line) => line.length)); } static calculateMaterialWidth(asset) { let maxWidth = 0; for (const layer of asset.layers) { const layerWidth = Math.max(...layer.lines.map((line) => line.length)); maxWidth = Math.max(maxWidth, layerWidth); } return maxWidth; } static calculateMaterialHeight(asset) { let maxHeight = 0; for (const layer of asset.layers) { maxHeight = Math.max(maxHeight, layer.lines.length); } return maxHeight; } static calculateSpriteWidth(asset) { let maxWidth = 0; for (const frame of asset.frames) { for (const line of frame.lines) { const lineWidth = line.length; maxWidth = Math.max(maxWidth, lineWidth); } } return maxWidth; } static calculateSpriteHeight(asset) { let maxHeight = 0; for (const frame of asset.frames) { maxHeight = Math.max(maxHeight, frame.lines.length); } return maxHeight; } // Private asset loading methods static async loadMapAsset(name) { try { // Load map.art and legend.json const [mapData, legendData] = await Promise.all([ loadArt(`art/maps/${name}/map.art`), loadArt(`art/maps/${name}/legend.json`), ]); const mapLines = mapData.split('\n'); const parsedLegend = JSON.parse(legendData); const legend = this.expandLegendFormat(parsedLegend); return { mapData: mapLines, legend, }; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to load map "${name}": ${message}`); } } /** * Expands legend format from array format to character map * - Format: { "legend": [ { "chars": [...], ...props } ] } */ static expandLegendFormat(data) { const result = {}; // Check for array format if (data.legend && Array.isArray(data.legend)) { // Expand each entry's chars array for (const entry of data.legend) { if (!entry.chars || !Array.isArray(entry.chars)) { console.warn('Legend entry missing chars array:', entry); continue; } // Create a LegendEntry without the chars property const legendEntry = { solid: entry.solid, material: entry.material, ...(entry.name && { name: entry.name }), ...(entry.showOnMap !== undefined && { showOnMap: entry.showOnMap }), ...(entry.entity && { entity: entry.entity }), ...(entry.variant && { variant: entry.variant }), }; // Expand each character in the chars array for (const char of entry.chars) { result[char] = legendEntry; } } } else { console.warn('Invalid legend format: expected { "legend": [...] }'); } return result; } static async loadMaterialAsset(name) { try { const materialData = await loadArt(`art/materials/${name}.art`); return this.parseMaterialData(materialData); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to load material "${name}": ${message}`); } } static async loadSpriteAsset(name) { try { const spriteData = await loadArt(`art/sprites/${name}.art`); return this.parseSpriteData(spriteData); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to load sprite "${name}": ${message}`); } } static async loadFontAsset(name) { try { const fontData = await loadArt(`art/fonts/${name}.art`); return this.parseFontData(fontData); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to load font "${name}": ${message}`); } } // Material parsing (from FirstPersonCompositor logic) static parseMaterialData(data) { // Split by § to separate file header from content const fileParts = data.split('§'); if (fileParts.length < 2) { throw new Error('Invalid material file format: missing § header'); } // Parse file header const headerSection = fileParts[1]; const headerLines = headerSection.split('\n'); const headerMetadataLine = headerLines[0].trim(); let fileMetadata; let isSceneFormat = false; try { fileMetadata = JSON.parse(headerMetadataLine); // Check for scenes format (§ contains layer info directly) if (fileMetadata.artType === 'fpsScenery' && fileMetadata.layer) { isSceneFormat = true; } // Check for materials format (§ contains file metadata) else if (fileMetadata.kind === 'material') { isSceneFormat = false; } else { throw new Error('File is not a material or scene'); } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to parse file metadata: ${message}`); } const layers = []; if (isSceneFormat) { // Scenes format: § line is the first layer, content follows immediately const contentAfterHeader = headerSection.substring(headerLines[0].length + 1); // Add the first layer from the § metadata const firstLayerLines = []; const remainingLines = contentAfterHeader.split('\n'); // Find where the first ¶ separator is let firstSeparatorIndex = -1; for (let i = 0; i < remainingLines.length; i++) { if (remainingLines[i].trim().startsWith('¶')) { firstSeparatorIndex = i; break; } } // First layer content const firstLayerContent = firstSeparatorIndex === -1 ? remainingLines : remainingLines.slice(0, firstSeparatorIndex); layers.push({ layer: fileMetadata.layer, position: fileMetadata.position, x: fileMetadata.x, lines: firstLayerContent, }); // Process remaining ¶-separated sections if any if (firstSeparatorIndex !== -1) { const remainingSections = remainingLines .slice(firstSeparatorIndex) .join('\n') .split('¶'); for (const section of remainingSections) { if (!section.trim()) continue; this.parseLayerSection(section, layers); } } } else { // Materials format: content after § header is all ¶-separated layers const contentAfterHeader = headerSection.substring(headerLines[0].length + 1); const layerSections = contentAfterHeader.split('¶'); for (const section of layerSections) { if (!section.trim()) continue; this.parseLayerSection(section, layers); } } return { usage: fileMetadata.usage || (isSceneFormat ? 'first-person' : 'unknown'), placement: fileMetadata.placement, onEnterSound: fileMetadata.onEnterSound, onExitSound: fileMetadata.onExitSound, ambientSound: fileMetadata.ambientSound, layers, }; } static parseLayerSection(section, layers) { const lines = section.split('\n'); if (lines.length === 0) return; // Parse metadata from first line const metadataLine = lines[0].trim(); if (!metadataLine.startsWith('{')) return; try { const metadata = JSON.parse(metadataLine); if (metadata.layer && metadata.position) { const spriteLines = lines.slice(1); layers.push({ layer: metadata.layer, position: metadata.position, x: metadata.x, lines: spriteLines, }); } } catch (error) { console.warn('Failed to parse layer metadata:', metadataLine, error); } } // Sprite parsing using § and ¶ separators // Format: § {sprite metadata} followed by frames separated by ¶ {frame metadata} // Example: // § {"kind":"sprite","loop":true,"default-frame-rate":120} // frame 1 content... // ¶ {"duration":1000} // frame 2 content... static parseSpriteData(data) { const frames = []; let defaults = {}; // Split by § to separate file header from content const fileParts = data.split('§'); if (fileParts.length < 2) { // No § separator, treat as simple single-frame sprite return { frames: [{ lines: this.normalizeBlock(data.split('\n')), meta: {} }], defaults: {}, }; } // Parse file header for defaults const headerSection = fileParts[1]; const headerLines = headerSection.split('\n'); const headerMetadataLine = headerLines[0].trim(); try { const fileMetadata = JSON.parse(headerMetadataLine); if (fileMetadata.kind === 'sprite') { defaults = { duration: fileMetadata['default-frame-rate'] || fileMetadata.duration, loop: fileMetadata.loop, transparent: typeof fileMetadata.transparent === 'string' && fileMetadata.transparent.length === 1 ? fileMetadata.transparent : undefined, }; } } catch (error) { console.warn('Failed to parse sprite file metadata:', headerMetadataLine, error); } // Get content after header line const contentAfterHeader = headerSection.substring(headerLines[0].length + 1); // Split by ¶ to get individual frame sections const frameSections = contentAfterHeader.split('¶'); // First section (before any ¶) is the first frame if (frameSections[0] && frameSections[0].trim()) { const firstFrameLines = frameSections[0].split('\n'); frames.push({ lines: this.normalizeBlock(firstFrameLines), meta: { duration: defaults.duration }, }); } // Process remaining ¶-separated frame sections for (let i = 1; i < frameSections.length; i++) { const section = frameSections[i]; if (!section.trim()) continue; const lines = section.split('\n'); if (lines.length === 0) continue; // Parse metadata from first line const metadataLine = lines[0].trim(); let frameMeta = { duration: defaults.duration }; if (metadataLine.startsWith('{')) { try { const metadata = JSON.parse(metadataLine); frameMeta = { duration: metadata.duration || defaults.duration, ...(metadata.sound && { sound: metadata.sound }), }; } catch (error) { console.warn('Failed to parse frame metadata:', metadataLine, error); } // Frame content is everything after the metadata line const frameLines = lines.slice(1); frames.push({ lines: this.normalizeBlock(frameLines), meta: frameMeta, }); } else { // No metadata, treat entire section as frame content frames.push({ lines: this.normalizeBlock(lines), meta: frameMeta, }); } } return { frames, defaults }; } // Font parsing using § and ¶ separators // Format: § {"kind":"font"} followed by glyphs separated by ¶ {"character":"a"} static parseFontData(data) { const glyphs = new Map(); let maxHeight = 0; // Split by § to separate file header from content const fileParts = data.split('§'); if (fileParts.length < 2) { throw new Error('Invalid font file format: missing § header'); } // Parse file header const headerSection = fileParts[1]; const headerLines = headerSection.split('\n'); const headerMetadataLine = headerLines[0].trim(); try { const fileMetadata = JSON.parse(headerMetadataLine); if (fileMetadata.kind !== 'font') { throw new Error('File is not a font'); } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to parse font file metadata: ${message}`); } // Get content after header line const contentAfterHeader = headerSection.substring(headerLines[0].length + 1); // Split by ¶ to get individual glyph sections const glyphSections = contentAfterHeader.split('¶'); // Process each ¶-separated glyph section for (let i = 1; i < glyphSections.length; i++) { const section = glyphSections[i]; if (!section.trim()) continue; const lines = section.split('\n'); if (lines.length === 0) continue; // Parse metadata from first line const metadataLine = lines[0].trim(); if (!metadataLine.startsWith('{')) continue; try { const metadata = JSON.parse(metadataLine); if (!metadata.character) { console.warn('Glyph metadata missing character property:', metadataLine); continue; } // Support both string and array of characters const characters = Array.isArray(metadata.character) ? metadata.character : [metadata.character]; // Validate all characters are strings if (!characters.every((c) => typeof c === 'string')) { console.warn('Glyph character must be string or array of strings:', metadataLine); continue; } // Glyph content is everything after the metadata line // Use normalizeFontBlock to preserve vertical positioning (no leading line removal) const glyphLines = this.normalizeFontBlock(lines.slice(1)); const glyphHeight = glyphLines.length; const glyphWidth = Math.max(...glyphLines.map((line) => line.length), 0); const glyph = { character: characters[0], // Store first character as primary lines: glyphLines, width: glyphWidth, height: glyphHeight, }; // Map all characters to the same glyph for (const char of characters) { glyphs.set(char, glyph); } maxHeight = Math.max(maxHeight, glyphHeight); } catch (error) { console.warn('Failed to parse glyph metadata:', metadataLine, error); } } if (glyphs.size === 0) { throw new Error('No valid glyphs found in font file'); } return { glyphs, height: maxHeight, }; } // Helper method to normalize sprite blocks (from Art component) static 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; } // Helper method to normalize font glyph blocks // Unlike normalizeBlock, this preserves ALL lines including leading empty lines // to maintain vertical positioning of glyphs static normalizeFontBlock(blockLines) { const result = blockLines.map((line) => [...line]); return result; } } // Promise caches to prevent duplicate loading AssetManager.mapCache = new Map(); AssetManager.materialCache = new Map(); AssetManager.spriteCache = new Map(); AssetManager.fontCache = new Map();