UNPKG

@minecraft/creator-tools

Version:

Minecraft Creator Tools command line and libraries.

374 lines (373 loc) 13.3 kB
"use strict"; // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. Object.defineProperty(exports, "__esModule", { value: true }); /** * Evaluates Molang expressions against an entity context. * * Design: stateless evaluator — create once, call evaluate() many times with * different expressions and contexts. Array definitions are passed per-call * since they come from the render controller, not the entity. */ class MolangEvaluator { /** * Evaluate a Molang expression string and return the result. * * @param expression The Molang expression (e.g., "query.is_baby ? Texture.baby : Texture.default") * @param context Entity state context (query values, variables) * @param arrays Optional array definitions from render controller (e.g., {"Array.geos": ["Geometry.default", "Geometry.sheared"]}) * @returns The evaluated result — a number or a string reference */ evaluate(expression, context, arrays) { const trimmed = expression.trim(); if (trimmed.length === 0) { return 0; } const parser = new ExpressionParser(trimmed, context, arrays); return parser.parseTernary(); } /** * Evaluate a Molang expression and return only the numeric result. * String references resolve to 0. */ evaluateNumber(expression, context, arrays) { const result = this.evaluate(expression, context, arrays); return typeof result === "number" ? result : 0; } /** * Evaluate a Molang expression and return only the string result. * Numeric results resolve to empty string. */ evaluateString(expression, context, arrays) { const result = this.evaluate(expression, context, arrays); return typeof result === "string" ? result : ""; } } exports.default = MolangEvaluator; /** * Recursive descent parser/evaluator for a single Molang expression. * Handles operator precedence via grammar rules: * * ternary → logicalOr ('?' ternary ':' ternary)? * logicalOr → logicalAnd ('||' logicalAnd)* * logicalAnd → equality ('&&' equality)* * equality → comparison (('==' | '!=') comparison)* * comparison → additive (('<' | '>' | '<=' | '>=') additive)* * additive → multiplicative (('+' | '-') multiplicative)* * multiplicative → unary (('*' | '/') unary)* * unary → '!' unary | primary * primary → NUMBER | REFERENCE | '(' ternary ')' | arrayAccess */ class ExpressionParser { _expr; _pos; _context; _arrays; constructor(expr, context, arrays) { this._expr = expr; this._pos = 0; this._context = context; this._arrays = arrays; } parseTernary() { const condition = this.parseLogicalOr(); this.skipWhitespace(); if (this.peek() === "?") { this.advance(); // skip '?' const trueBranch = this.parseTernary(); this.skipWhitespace(); this.expect(":"); const falseBranch = this.parseTernary(); return this.isTruthy(condition) ? trueBranch : falseBranch; } return condition; } parseLogicalOr() { let left = this.parseLogicalAnd(); this.skipWhitespace(); while (this.matchStr("||")) { const right = this.parseLogicalAnd(); left = (this.isTruthy(left) || this.isTruthy(right)) ? 1 : 0; } return left; } parseLogicalAnd() { let left = this.parseEquality(); this.skipWhitespace(); while (this.matchStr("&&")) { const right = this.parseEquality(); left = (this.isTruthy(left) && this.isTruthy(right)) ? 1 : 0; } return left; } parseEquality() { let left = this.parseComparison(); this.skipWhitespace(); if (this.matchStr("==")) { const right = this.parseComparison(); return this.toNumber(left) === this.toNumber(right) ? 1 : 0; } if (this.matchStr("!=")) { const right = this.parseComparison(); return this.toNumber(left) !== this.toNumber(right) ? 1 : 0; } return left; } parseComparison() { let left = this.parseAdditive(); this.skipWhitespace(); if (this.matchStr("<=")) { return this.toNumber(left) <= this.toNumber(this.parseAdditive()) ? 1 : 0; } if (this.matchStr(">=")) { return this.toNumber(left) >= this.toNumber(this.parseAdditive()) ? 1 : 0; } if (this.matchStr("<")) { return this.toNumber(left) < this.toNumber(this.parseAdditive()) ? 1 : 0; } if (this.matchStr(">")) { return this.toNumber(left) > this.toNumber(this.parseAdditive()) ? 1 : 0; } return left; } parseAdditive() { let left = this.parseMultiplicative(); this.skipWhitespace(); while (true) { if (this.matchStr("+")) { left = this.toNumber(left) + this.toNumber(this.parseMultiplicative()); } else if (this.matchStr("-")) { left = this.toNumber(left) - this.toNumber(this.parseMultiplicative()); } else { break; } this.skipWhitespace(); } return left; } parseMultiplicative() { let left = this.parseUnary(); this.skipWhitespace(); while (true) { if (this.matchStr("*")) { left = this.toNumber(left) * this.toNumber(this.parseUnary()); } else if (this.matchStr("/")) { const right = this.toNumber(this.parseUnary()); left = right !== 0 ? this.toNumber(left) / right : 0; } else { break; } this.skipWhitespace(); } return left; } parseUnary() { this.skipWhitespace(); if (this.matchStr("!")) { const val = this.parseUnary(); return this.isTruthy(val) ? 0 : 1; } return this.parsePrimary(); } parsePrimary() { this.skipWhitespace(); // Parenthesized expression if (this.peek() === "(") { this.advance(); const val = this.parseTernary(); this.skipWhitespace(); if (this.peek() === ")") { this.advance(); } return val; } // Number literal (including negative and decimal) if (this.isDigit(this.peek()) || (this.peek() === "-" && this.isDigit(this.peekAt(1)))) { return this.parseNumber(); } // Identifier/reference (query.x, variable.y, Texture.default, Array.geos, etc.) if (this.isIdentStart(this.peek())) { return this.parseReference(); } // Single-quoted string literal if (this.peek() === "'") { return this.parseStringLiteral(); } // Unknown — return 0 return 0; } parseNumber() { let numStr = ""; if (this.peek() === "-") { numStr += "-"; this.advance(); } while (this._pos < this._expr.length && (this.isDigit(this.peek()) || this.peek() === ".")) { numStr += this.peek(); this.advance(); } return parseFloat(numStr) || 0; } parseStringLiteral() { this.advance(); // skip opening ' let str = ""; while (this._pos < this._expr.length && this.peek() !== "'") { str += this.peek(); this.advance(); } if (this.peek() === "'") { this.advance(); // skip closing ' } return str; } parseReference() { let ident = this.parseIdentifier(); // Expand short forms if (ident === "q") ident = "query"; else if (ident === "v") ident = "variable"; else if (ident === "t") ident = "temp"; else if (ident === "c") ident = "context"; // Check for dot-notation: query.is_baby, Texture.default, Array.geos if (this.peek() === ".") { this.advance(); // skip '.' const member = this.parseIdentifier(); const fullRef = ident + "." + member; // Handle query lookups if (ident === "query") { return this._context.queries.get(fullRef) ?? this._context.queries.get("query." + member) ?? 0; } // Handle variable lookups if (ident === "variable") { return this._context.variables.get(fullRef) ?? this._context.variables.get("variable." + member) ?? 0; } // Handle temp lookups if (ident === "temp") { return this._context.temps.get(fullRef) ?? this._context.temps.get("temp." + member) ?? 0; } // Handle Array access: Array.geos[index] if (ident === "Array" || ident === "array") { this.skipWhitespace(); if (this.peek() === "[") { this.advance(); // skip '[' const indexVal = this.parseTernary(); this.skipWhitespace(); if (this.peek() === "]") { this.advance(); // skip ']' } return this.resolveArrayAccess(fullRef, this.toNumber(indexVal)); } // Array reference without index — return as string return fullRef; } // Anything else (Texture.default, Geometry.baby, Material.body) — return as string reference return fullRef; } // Check for function call: math.sin(x) — simplified, just skip args for now if (this.peek() === "(") { // For now, evaluate function arguments but return 0 for unsupported functions this.advance(); // skip '(' if (this.peek() !== ")") { this.parseTernary(); // evaluate argument (discard result) } if (this.peek() === ")") { this.advance(); // skip ')' } return 0; } // Bare identifier — try as variable const queryVal = this._context.queries.get("query." + ident); if (queryVal !== undefined) return queryVal; const varVal = this._context.variables.get("variable." + ident); if (varVal !== undefined) return varVal; // Could be "this" keyword (passthrough in color context) if (ident === "this") return "this"; return ident; } resolveArrayAccess(arrayName, index) { if (!this._arrays) return arrayName; const arr = this._arrays.get(arrayName); if (!arr || arr.length === 0) return arrayName; // Molang uses integer index, clamp to valid range const idx = Math.max(0, Math.min(Math.floor(index), arr.length - 1)); return arr[idx]; } parseIdentifier() { let id = ""; while (this._pos < this._expr.length && this.isIdentChar(this.peek())) { id += this.peek(); this.advance(); } return id; } // -- Helpers -- isTruthy(val) { if (typeof val === "number") return val !== 0; if (typeof val === "string") return val.length > 0; return false; } toNumber(val) { if (typeof val === "number") return val; return 0; } peek() { return this._pos < this._expr.length ? this._expr[this._pos] : ""; } peekAt(offset) { const idx = this._pos + offset; return idx < this._expr.length ? this._expr[idx] : ""; } advance() { this._pos++; } skipWhitespace() { while (this._pos < this._expr.length && /\s/.test(this._expr[this._pos])) { this._pos++; } } matchStr(s) { this.skipWhitespace(); if (this._expr.substring(this._pos, this._pos + s.length) === s) { // For multi-char operators, make sure next char isn't part of a longer operator if (s.length === 1 && (s === "<" || s === ">" || s === "!")) { const next = this.peekAt(s.length); if (next === "=") return false; } this._pos += s.length; return true; } return false; } expect(s) { this.skipWhitespace(); if (this._expr.substring(this._pos, this._pos + s.length) === s) { this._pos += s.length; } // Silently skip if missing — defensive for malformed expressions } isDigit(ch) { return ch >= "0" && ch <= "9"; } isIdentStart(ch) { return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_"; } isIdentChar(ch) { return this.isIdentStart(ch) || this.isDigit(ch) || ch === "_"; } }