From 13b374d7f8d33f1af7159b967934b8290739ee79 Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Thu, 26 Mar 2026 13:11:06 -0400 Subject: [PATCH] feat: implement smart texture resolution and indexing for improved asset management --- web/server/server.js | 242 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 239 insertions(+), 3 deletions(-) diff --git a/web/server/server.js b/web/server/server.js index ef1abc2..f711317 100644 --- a/web/server/server.js +++ b/web/server/server.js @@ -188,11 +188,247 @@ const TEXTURE_UPSTREAMS = { // Ensure cache directory exists fs.mkdirSync(TEXTURE_CACHE_DIR, { recursive: true }); +// ── Texture index: maps base_name → relative path for smart resolution ──── +// Built on startup by scanning the texture cache. Key = short name (no ext), +// Value = array of relative paths (e.g. ["item/diamond.png", "block/diamond_ore.png"]) +const textureIndex = {}; // { namespace: { baseName: [relPath, ...] } } + +function buildTextureIndex() { + for (const ns of Object.keys(TEXTURE_UPSTREAMS)) { + textureIndex[ns] = {}; + const nsDir = path.join(TEXTURE_CACHE_DIR, ns); + if (!fs.existsSync(nsDir)) continue; + (function walk(dir, rel) { + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) walk(path.join(dir, entry.name), childRel); + else if (entry.name.endsWith('.png') && !entry.name.endsWith('.miss')) { + const baseName = entry.name.slice(0, -4); // strip .png + if (!textureIndex[ns][baseName]) textureIndex[ns][baseName] = []; + textureIndex[ns][baseName].push(childRel); + } + } + } catch (_) { /* ignore unreadable dirs */ } + })(nsDir, ''); + } + const total = Object.values(textureIndex).reduce( + (sum, idx) => sum + Object.keys(idx).length, 0); + console.log(`[Texture Index] Indexed ${total} unique texture names`); +} + +buildTextureIndex(); + +// ── Smart texture resolution ────────────────────────────────────────────── +// Given a Minecraft item/block registry name, find the best matching texture. +// Returns the relative cache path or null. + +// Patterns for items whose textures use numbered animation frames +const ANIMATED_ITEMS = new Set([ + 'compass', 'recovery_compass', 'clock', + 'crossbow', // crossbow_standby, crossbow_pulling_0, etc. + 'light', // light_0 .. light_15 +]); + +// Maps a registry short name to the actual texture base name +const SERVER_ALIASES = { + // Registry name differs from texture filename + magma_block: 'magma', + grass: 'short_grass', + scute: 'turtle_scute', + enchanted_golden_apple: 'golden_apple', +}; + +// Preferred animation frame for animated items +const ANIMATED_FRAME = { + compass: 'compass_16', + recovery_compass: 'recovery_compass_16', + clock: 'clock_22', + crossbow: 'crossbow_standby', + light: 'light_15', +}; + +// Dye color set for carpet/bed/banner resolution +const DYE_COLORS = new Set([ + 'white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime', + 'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue', + 'brown', 'green', 'red', 'black', +]); + +const WOOD_TYPES = new Set([ + 'oak', 'spruce', 'birch', 'jungle', 'acacia', 'dark_oak', + 'mangrove', 'cherry', 'bamboo', 'crimson', 'warped', 'pale_oak', +]); + +// Blocks whose texture uses a suffix: name_front, name_top, etc. +const BLOCK_SUFFIXES = { + furnace: '_front_on', blast_furnace: '_front_on', smoker: '_front_on', + dispenser: '_front', dropper: '_front', observer: '_front', + barrel: '_top', crafting_table: '_top', grass_block: '_top', + mycelium: '_top', podzol: '_top', dirt_path: '_top', + quartz_block: '_side', + pumpkin: '_side', carved_pumpkin: '_front', jack_o_lantern: '_front', + jukebox: '_top', loom: '_front', bee_nest: '_front', beehive: '_front', + respawn_anchor: '_top', bone_block: '_side', + basalt: '_side', polished_basalt: '_side', + tnt: '_side', composter: '_side', enchanting_table: '_top', + cartography_table: '_top', fletching_table: '_top', smithing_table: '_top', +}; + +// Derivative block suffixes → resolve to parent block +const DERIVATIVE_SUFFIXES = [ + '_stairs', '_slab', '_fence_gate', '_fence', '_wall', + '_button', '_pressure_plate', +]; +const STONE_ALIASES = { + brick: 'bricks', stone_brick: 'stone_bricks', mossy_stone_brick: 'mossy_stone_bricks', + nether_brick: 'nether_bricks', red_nether_brick: 'red_nether_bricks', + end_stone_brick: 'end_stone_bricks', deepslate_brick: 'deepslate_bricks', + deepslate_tile: 'deepslate_tiles', polished_blackstone_brick: 'polished_blackstone_bricks', + mud_brick: 'mud_bricks', quartz: 'quartz_block', purpur: 'purpur_block', + smooth_stone: 'smooth_stone', +}; +// "smooth_" blocks use the parent's top/bottom face +const SMOOTH_ALIASES = { + smooth_sandstone: 'sandstone_top', + smooth_red_sandstone: 'red_sandstone_top', + smooth_quartz: 'quartz_block_bottom', +}; + +function resolveTexturePath(ns, itemName) { + const idx = textureIndex[ns]; + if (!idx) return null; + + // Helper: check if a baseName exists and return preferred path + function find(baseName, preferFolder) { + const paths = idx[baseName]; + if (!paths || paths.length === 0) return null; + if (preferFolder) { + const match = paths.find(p => p.startsWith(preferFolder + '/')); + if (match) return match; + } + return paths[0]; + } + + // Helper: check if a baseName with a suffix exists in block/ + function findBlock(baseName) { + const suffix = BLOCK_SUFFIXES[baseName]; + if (suffix) { + const hit = find(baseName + suffix, 'block') || find(baseName, 'block'); + if (hit) return hit; + } + return find(baseName, 'block'); + } + + // 1. Direct lookup — try item/ then block/ + let hit = find(itemName, 'item') || findBlock(itemName); + if (hit) return hit; + + // 2. Server-side alias + if (SERVER_ALIASES[itemName]) { + const alias = SERVER_ALIASES[itemName]; + hit = find(alias, 'item') || findBlock(alias); + if (hit) return hit; + } + + // 3. Smooth blocks + if (SMOOTH_ALIASES[itemName]) { + hit = find(SMOOTH_ALIASES[itemName], 'block'); + if (hit) return hit; + } + + // 4. Animated items — pick a representative frame + if (ANIMATED_FRAME[itemName]) { + hit = find(ANIMATED_FRAME[itemName], 'item'); + if (hit) return hit; + } + + // 5. Carpets → wool texture + if (itemName.endsWith('_carpet')) { + const color = itemName.slice(0, -'_carpet'.length); + if (DYE_COLORS.has(color)) { + hit = find(color + '_wool', 'block'); + if (hit) return hit; + } + } + + // 6. Wood → log texture + if (itemName.endsWith('_wood')) { + const base = itemName.slice(0, -'_wood'.length); + const stripped = base.startsWith('stripped_') ? base.slice('stripped_'.length) : null; + const woodType = stripped || base; + if (WOOD_TYPES.has(woodType)) { + const logName = stripped ? `stripped_${woodType}_log` : `${woodType}_log`; + hit = find(logName, 'block'); + if (hit) return hit; + } + } + + // 7. Derivative blocks (stairs, slabs, fences, walls…) → parent block + for (const suffix of DERIVATIVE_SUFFIXES) { + if (!itemName.endsWith(suffix)) continue; + const base = itemName.slice(0, -suffix.length); + // Try direct parent + hit = findBlock(base); + if (hit) return hit; + // Wood → planks + if (WOOD_TYPES.has(base)) { + hit = findBlock(base + '_planks'); + if (hit) return hit; + } + // Stone aliases (brick → bricks, etc.) + if (STONE_ALIASES[base]) { + hit = findBlock(STONE_ALIASES[base]); + if (hit) return hit; + } + break; // only one derivative suffix can match + } + + // 8. Prefix match — for items like "compass" try "compass_*" + const prefixMatches = Object.keys(idx).filter(k => k.startsWith(itemName + '_')); + if (prefixMatches.length > 0) { + // Sort for deterministic result, prefer item/ folder + prefixMatches.sort(); + for (const m of prefixMatches) { + hit = find(m, 'item'); + if (hit) return hit; + } + hit = find(prefixMatches[0]); + if (hit) return hit; + } + + return null; +} + +// ── Smart resolution endpoint ───────────────────────────────────────────── +// GET /api/texture/resolve/:namespace/:itemName +// Returns the texture PNG for a Minecraft registry item name, using smart +// matching (aliases, animation frames, carpet→wool, etc.) +app.get('/api/texture/resolve/:namespace/:itemName', (req, res) => { + const { namespace, itemName } = req.params; + const name = itemName.replace(/\.png$/i, ''); + + const resolved = resolveTexturePath(namespace, name); + if (!resolved) { + return res.status(404).send('No texture found'); + } + + const fullPath = path.join(TEXTURE_CACHE_DIR, namespace, resolved); + if (!fs.existsSync(fullPath)) { + return res.status(404).send('Texture file missing'); + } + + res.set('Content-Type', 'image/png'); + res.set('Cache-Control', 'public, max-age=604800'); + res.set('X-Texture-Resolved', resolved); + return res.sendFile(fullPath); +}); + +// ── Legacy texture proxy (fallback for cache misses) ────────────────────── app.get('/api/texture/:namespace/*', async (req, res) => { const { namespace } = req.params; - const texturePath = req.params[0]; // e.g. "item/diamond.png" - - const upstream = TEXTURE_UPSTREAMS[namespace]; + const texturePath = req.params[0]; + const upstream = UPSTREAM_URLS[namespace]; if (!upstream) { return res.status(404).send('Unknown namespace'); }