harden blockstate.js
This commit is contained in:
186
src/blockstate.js
Normal file
186
src/blockstate.js
Normal file
@@ -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<string, string>} 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<string, ResolverFunction>} */
|
||||
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<string, Promise<void>>} */
|
||||
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');
|
||||
}
|
||||
}
|
||||
132
src/minerama.js
132
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<void>}
|
||||
*/
|
||||
async function parse(src) {
|
||||
const lines = src.replace(/#.*?(?:\n|$)/, '\n').trim().split(/(?:\n[ \t]*)+/);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user