feat: implement smart texture resolution and indexing for improved asset management
This commit is contained in:
@@ -188,11 +188,247 @@ const TEXTURE_UPSTREAMS = {
|
|||||||
// Ensure cache directory exists
|
// Ensure cache directory exists
|
||||||
fs.mkdirSync(TEXTURE_CACHE_DIR, { recursive: true });
|
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) => {
|
app.get('/api/texture/:namespace/*', async (req, res) => {
|
||||||
const { namespace } = req.params;
|
const { namespace } = req.params;
|
||||||
const texturePath = req.params[0]; // e.g. "item/diamond.png"
|
const texturePath = req.params[0];
|
||||||
|
const upstream = UPSTREAM_URLS[namespace];
|
||||||
const upstream = TEXTURE_UPSTREAMS[namespace];
|
|
||||||
if (!upstream) {
|
if (!upstream) {
|
||||||
return res.status(404).send('Unknown namespace');
|
return res.status(404).send('Unknown namespace');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user