diff --git a/src/blockstate.js b/src/blockstate.js new file mode 100644 index 00000000..433a25ce --- /dev/null +++ b/src/blockstate.js @@ -0,0 +1,186 @@ +/** + * A dictionary representing the exact properties of a block in the world. + * All property values must be normalized to strings (e.g., { "facing": "north", "waterlogged": "false" }). + * @typedef {Object} BlockStateMap + */ + +/** + * A Minecraft block model application definition. + * @typedef {Object} BlockModel + * @property {string} model - The namespace ID of the model (e.g., "minecraft:block/stone"). + * @property {number} [x] - X-axis rotation in degrees (defaults to 0). + * @property {number} [y] - Y-axis rotation in degrees (defaults to 0). + * @property {boolean} [uvlock] - Whether to lock UV coordinates during rotation (defaults to false). + * @property {number} [weight] - Selection weight for randomized model arrays (defaults to 1). + */ + +/** + * A JIT-compiled function that resolves a block state and a spatial hash into an array of models. + * @typedef {function(BlockStateMap, number): BlockModel[]} ResolverFunction + */ + +/** + * Renders a standard comma-separated variant key into a boolean JavaScript condition. + * @param {string} key - The variant key (e.g., "facing=north,half=top"). + * @returns {string} The compiled JavaScript condition. + */ +function renderVariantCondition(key) { + return key.split(',') + .map(it => it.split("=")) + .map(([n, v]) => `state[${JSON.stringify(n)}] === ${JSON.stringify(v)}`) + .join(" && "); +} + +/** + * Renders a multipart 'when' condition (including OR/AND arrays) into a boolean JavaScript condition. + * @param {Object} [when] - The 'when' condition object from a multipart definition. + * @returns {string} The compiled JavaScript condition. + */ +function renderWhenCondition(when) { + if (!when) return "true"; + + if (when["OR"]) { + return when["OR"] + .map(it => `(${renderWhenCondition(it)})`) + .join(" || "); + } + if (when["AND"]) { + return when["AND"] + .map(it => `(${renderWhenCondition(it)})`) + .join(" && "); + } + + if (Object.entries(when).length === 0) return 'true'; + + return Object.entries(when) + .map(([key, vals]) => vals.toString().split('|') + .map(val => `state[${JSON.stringify(key)}] === ${JSON.stringify(val)}`) + .join(' || ')) + .map(it => `(${it})`) + .join(' && '); +} + +/** + * Renders a model application definition (single or weighted array) into a JavaScript return string. + * @param {BlockModel|BlockModel[]} data - The 'apply' property of a blockstate variant or multipart. + * @returns {string} The compiled JavaScript return value. + */ +function renderApply(data) { + if (Array.isArray(data)) { + const parts = []; + + let total = 0.0; + for (const {weight = 1.0} of data) total += weight; + + let accum = 0.0; + for (const elem of data) { + const {weight = 1.0} = elem; + accum += weight; + + if (accum === total) { + parts.push(JSON.stringify(elem)); + } else { + parts.push(`h < ${accum} ? ${JSON.stringify(elem)} : `); + } + } + return `(h => ${parts.join('')})(Math.abs(hash) % ${total})`; + } else { + return JSON.stringify(data); + } +} + +/** + * Compiles a raw Minecraft blockstate JSON object into the source code for a resolver function. + * @param {Object} data - The parsed blockstate JSON (containing either 'variants' or 'multipart'). + * @returns {string} The raw JavaScript function body. + */ +function renderBlockstateResolver(data) { + const {variants, multipart} = data; + + if (variants) { + let cases = []; + for (const [key, apply] of Object.entries(variants)) { + if (!key || key === "normal") continue; + cases.push(`if (${(renderVariantCondition(key))}) return [${(renderApply(apply))}];`); + } + let fallback = variants[""] ?? variants["normal"] ?? {"model": ":invalid"}; + cases.push(`return [${renderApply(fallback)}];`); + return cases.join('else '); + } else if (multipart) { + const parts = []; + parts.push('const parts = [];'); + for (const {when, apply} of multipart) { + parts.push(`if (${renderWhenCondition(when)}) parts.push(${renderApply(apply)});`); + } + parts.push("return parts;"); + return parts.join(''); + } else { + console.warn('invalid blockstate', data); + return 'return [{"model": ":invalid"}];'; + } +} + +/** + * Manages the fetching, compilation, and caching of JIT-compiled blockstate resolvers. + */ +export class BlockstateResolver { + constructor() { + /** @type {Map} */ + this.resolvers = new Map(); + + // Define default fallback resolvers + this.resolvers.set(':pending', (state, hash) => [{"model": ":pending"}]); + this.resolvers.set(':invalid', (state, hash) => [{"model": ":invalid"}]); + + /** @type {Map>} */ + this.pending = new Map(); + + /** + * Callback fired when a pending blockstate finishes downloading and compiling. + * @type {((id: string, fn: ResolverFunction) => void) | undefined} + */ + this.onResolve = undefined; + } + + /** + * Retrieves the compiled resolver for a given block ID. + * Triggers a network fetch and returns a `:pending` fallback if the resolver is not yet cached. + * @param {string} id - The block identifier (e.g., "minecraft:stone" or "stone"). + * @returns {ResolverFunction} The JIT-compiled resolver function. + */ + getResolver(id) { + id = id.includes(':') ? id : `minecraft:${id}`; + + if (this.resolvers.has(id)) { + return this.resolvers.get(id); + } + + if (!this.pending.has(id)) { + const [space, name] = id.split(':'); + + this.pending.set(id, fetch(`assets/${space}/blockstates/${name}.json`) + .then(res => res.json()) + .then(data => { + const source = renderBlockstateResolver(data); + + /** @type {ResolverFunction} */ + const resolver = new Function(`return function (state, hash) { ${source} }`)(); + + this.resolvers.set(id, resolver); + this.pending.delete(id); + + if (this.onResolve) { + this.onResolve(id, resolver); + } + }) + .catch(reason => { + this.resolvers.set(id, this.resolvers.get(':invalid')); + this.pending.delete(id); + console.log(`failed to resolve blockstate ${id}: ${reason}`); + }) + ); + } + + return this.resolvers.get(':pending'); + } +} \ No newline at end of file diff --git a/src/minerama.js b/src/minerama.js index 768ba8ee..87773834 100644 --- a/src/minerama.js +++ b/src/minerama.js @@ -1,3 +1,5 @@ +import {BlockstateResolver} from "./blockstate"; + export class Renderer { constructor() { this.canvas = document.createElement('canvas'); @@ -7,136 +9,6 @@ export class Renderer { } } -function renderVariantCondition(key) { - return key.split(',') - .map(it => it.split("=")) - .map(([n, v]) => `state[${JSON.stringify(n)}] === ${JSON.stringify(v)}`) - .join(" && "); -} - -function renderWhenCondition(when) { - if (!when) return "true"; - - if (when["OR"]) { - return when["OR"] - .map(it => `(${renderWhenCondition(it)})`) - .join(" || "); - } - if (when["AND"]) { - return when["AND"] - .map(it => `(${renderWhenCondition(it)})`) - .join(" && ") - } - - if (Object.entries(when).length === 0) return 'true'; - - return Object.entries(when) - .map(([key, vals]) => vals.toString().split('|') - .map(val => `state[${JSON.stringify(key)}] === ${JSON.stringify(val)}`) - .join(' || ')) - .map(it => `(${it})`) - .join(' && '); -} - -function renderApply(data) { - if (Array.isArray(data)) { - const parts = []; - - let total = 0.0; - for (const {weight = 1.0} of data) total += weight; - - let accum = 0.0; - for (const elem of data) { - const {weight = 1.0} = elem; - accum += weight; - - if (accum === total) { - parts.push(JSON.stringify(elem)); - } else { - parts.push(`h < ${accum} ? ${JSON.stringify(elem)} : `); - } - } - return `(h => ${parts.join('')})(Math.abs(hash) % ${total})`; - } else { - return JSON.stringify(data); - } -} - -/** - * @param {Object} data - * @returns {String} - */ -function renderBlockstateResolver(data) { - const {variants, multipart} = data; - - if (variants) { - let cases = []; - for (const [key, apply] of Object.entries(variants)) { - if (!key || key === "normal") continue; - cases.push(`if (${(renderVariantCondition(key))}) return [${(renderApply(apply))}];`); - } - let fallback = variants[""] ?? variants["normal"] ?? {"model": ":invalid"}; - cases.push(`return [${renderApply(fallback)}]`) - return cases.join('else '); - } else if (multipart) { - const parts = []; - parts.push('const parts = [];') - for (const {when, apply} of multipart) { - parts.push(`if (${renderWhenCondition(when)}) parts.push(${renderApply(apply)});`); - } - parts.push("return parts;"); - return parts.join(''); - } else { - console.warn('invalid blockstate', data); - return 'return [{"model": ":invalid"}]'; - } -} - - -export class BlockstateResolver { - constructor() { - this.resolvers = new Map(); - this.resolvers.set(':pending', (state, hash) => [{"model": ":pending"}]); - this.resolvers.set(':invalid', (state, hash) => [{"model": ":invalid"}]); - - this.pending = new Map(); - - this.onResolve = undefined; - } - - getResolver(id) { - id = id.includes(':') ? id : `minecraft:${id}`; - if (this.resolvers.has(id)) { - return this.resolvers.get(id); - } - if (!this.pending.has(id)) { - const [space, name] = id.split(':'); - this.pending.set(id, fetch(`assets/${space}/blockstates/${name}.json`) - .then(res => res.json()) - .then(data => { - const source = renderBlockstateResolver(data); - const resolver = new Function(`return function (state, hash) { ${source} }`)(); - this.resolvers.set(id, resolver); - this.pending.delete(id); - if (this.onResolve) { - this.onResolve(id, resolver); - } - }) - .catch(reason => { - this.resolvers.set(id, this.resolvers.get(':invalid')); - this.pending.delete(id); - console.log(`failed to resolve blockstate ${id}: ${reason}`) - }) - ) - } - return this.resolvers.get(':pending'); - } -} - -/** - * @param {String} src - * @returns {Promise} - */ async function parse(src) { const lines = src.replace(/#.*?(?:\n|$)/, '\n').trim().split(/(?:\n[ \t]*)+/);