Files
picoGraph/scripts/core/LuaGenerator.js
Max Litruv Boonzaayer f917cf6ad5 Add initial Pico-8 cartridges for testing
- Created test_cart.p8 with basic drawing functionality and a print statement.
- Created test_unix.p8 with a simple initialization function.
2026-03-21 18:38:43 +11:00

1197 lines
35 KiB
JavaScript

import { formatLiteral, defaultLiteralForKind } from './lua/LuaLiteralTypes.js';
import { normalizeVariableType, getTypeConverter } from '../types/TypeRegistry.js';
/**
* @typedef {import('./NodeGraph.js').NodeGraph} NodeGraph
*/
/**
* @typedef {import('./BlueprintNode.js').BlueprintNode} BlueprintNode
*/
/**
* @typedef {import('../nodes/NodeRegistry.js').NodeRegistry} NodeRegistry
*/
/**
* @typedef {Object} ProjectSettings
* @property {boolean} use60Fps Enables the 60fps update loop via _UPDATE60.
*/
/**
* Converts a node graph into executable PICO-8 Lua code.
*/
export class LuaGenerator {
/**
* @param {NodeRegistry} registry Node registry for metadata lookups.
*/
constructor(registry) {
this.registry = registry;
this.indentUnit = " ";
this.valueCache = new Map();
this.valueStack = new Set();
/** @type {Map<string, string>} */
this.customEventFunctionNames = new Map();
/**
* @type {Map<string, Array<{ id: string, name: string, luaName: string, outputPinId: string, inputPinId: string, kind: string }>>}
*/
this.customEventParameters = new Map();
/** @type {Map<string, string>} */
this.customEventParameterLookup = new Map();
/** @type {Array<{ id: string, name: string, type: string, defaultValue: unknown }>} */
this.variables = [];
/** @type {Map<string, string>} */
this.globalVariableNames = new Map();
/** @type {ProjectSettings} */
this.projectSettings = {
use60Fps: false,
};
}
/**
* Applies project-level configuration.
*
* @param {ProjectSettings} settings Project configuration flags.
*/
setProjectSettings(settings) {
this.projectSettings = {
use60Fps: Boolean(settings.use60Fps),
};
}
/**
* Updates the workspace variable metadata used for Lua generation.
*
* @param {Array<{ id: string, name: string, type: string, defaultValue: unknown }>} variables Variable definitions.
*/
setVariables(variables) {
if (!Array.isArray(variables)) {
this.variables = [];
return;
}
this.variables = variables.map((entry, index) => ({
id: typeof entry?.id === "string" ? entry.id : `variable_${index}`,
name: typeof entry?.name === "string" ? entry.name : "",
type: this.#normalizeVariableType(entry?.type),
defaultValue: Object.prototype.hasOwnProperty.call(entry ?? {}, "defaultValue")
? entry?.defaultValue
: null,
}));
}
/**
* Generates Lua code for the provided graph instance.
*
* @param {NodeGraph} graph Blueprint graph to compile.
* @returns {string}
*/
generate(graph, options = {}) {
this.graph = graph;
this.connections = graph.getConnections();
this.nodes = new Map(graph.getNodes().map((node) => [node.id, node]));
this.valueCache.clear();
this.valueStack.clear();
this.globalVariableNames.clear();
const variableEntries = Array.isArray(options)
? options
: Array.isArray(options?.variables)
? options.variables
: this.variables;
this.setVariables(variableEntries);
const globalDeclarations = this.#prepareGlobalVariableDeclarations(
this.variables
);
const use60Fps = Boolean(this.projectSettings.use60Fps);
const entryTypes = new Set(this.registry.getEntryNodeTypes());
const entryEventMap = this.registry.getEntryPointEvents();
const entryCandidates = [...this.nodes.values()].filter((node) =>
entryTypes.has(node.type)
);
/** @type {Array<BlueprintNode>} */
const customEvents = [];
/** @type {Array<BlueprintNode>} */
const entryNodes = [];
entryCandidates.forEach((node) => {
if (node.type === "custom_event") {
customEvents.push(node);
} else {
entryNodes.push(node);
}
});
if (!entryNodes.length && !customEvents.length) {
return ["-- Generated with picoGraph", "-- No entry node present."].join(
"\n"
);
}
this.customEventFunctionNames =
this.#buildCustomEventFunctionMap(customEvents);
const { parameterMap, lookupMap } =
this.#buildCustomEventParameterMap(customEvents);
this.customEventParameters = parameterMap;
this.customEventParameterLookup = lookupMap;
/** @type {Map<string, Array<BlueprintNode>>} */
const grouped = new Map();
entryNodes.forEach((node) => {
let eventName = entryEventMap.get(node.type) ?? "_init";
if (eventName === "_update" && use60Fps) {
eventName = "_update60";
}
const collection = grouped.get(eventName) ?? [];
collection.push(node);
grouped.set(eventName, collection);
});
const output = ["-- Generated with picoGraph"];
if (globalDeclarations.length) {
output.push("");
output.push(...globalDeclarations);
}
const appendEvent = (eventName, node) => {
const lines = this.#emitExecChain(node.id, 1, new Set());
output.push("");
output.push(`function ${eventName}()`);
if (lines.length) {
output.push(...lines);
}
output.push("end");
};
const preferredOrder = ["_init", "_update", "_update60", "_draw"];
preferredOrder.forEach((eventName) => {
const nodes = grouped.get(eventName);
if (!nodes || !nodes.length) {
return;
}
appendEvent(eventName, nodes[0]);
grouped.delete(eventName);
});
const remainingEvents = [...grouped.entries()].sort((a, b) =>
a[0].localeCompare(b[0])
);
remainingEvents.forEach(([eventName, nodes]) => {
if (!nodes.length) {
return;
}
appendEvent(eventName, nodes[0]);
});
if (customEvents.length) {
const orderedCustomEvents = customEvents
.slice()
.sort((a, b) =>
this.#getCustomEventDisplayName(a).localeCompare(
this.#getCustomEventDisplayName(b)
)
);
orderedCustomEvents.forEach((node) => {
const functionName = this.customEventFunctionNames.get(node.id);
if (!functionName) {
return;
}
const signature = this.customEventParameters.get(node.id) ?? [];
const parameterList = signature
.map((entry) => entry.luaName)
.join(", ");
const lines = this.#emitExecChain(node.id, 1, new Set());
output.push("");
if (parameterList.length) {
output.push(`function ${functionName}(${parameterList})`);
} else {
output.push(`function ${functionName}()`);
}
if (lines.length) {
output.push(...lines);
}
output.push("end");
});
}
return output.join("\n");
}
/**
* Recursively walks the exec chain starting at a node.
*
* @param {string} nodeId Starting node identifier.
* @param {number} indentLevel Indentation level.
* @param {Set<string>} path Guard set preventing infinite recursion.
* @returns {Array<string>}
*/
#emitExecChain(nodeId, indentLevel, path) {
if (path.has(nodeId)) {
return [
`${this.#indent(
indentLevel
)}-- cyclic exec connection involving ${nodeId}`,
];
}
path.add(nodeId);
const node = this.nodes.get(nodeId);
if (!node) {
return [`${this.#indent(indentLevel)}-- missing node ${nodeId}`];
}
const lines = this.#emitFlowNode(node, indentLevel, path);
path.delete(nodeId);
return lines;
}
/**
* Builds the execution context shared with node behaviors.
*
* @param {BlueprintNode} node Active node reference.
* @param {number} indentLevel Current indentation level.
* @param {Set<string>} path Cycle detection guard.
* @returns {import('../nodes/nodeTypes.js').ExecContext}
*/
#createExecContext(node, indentLevel, path) {
return {
node,
indentLevel,
path,
indent: (level = indentLevel) => this.#indent(level),
resolveValueInput: (pinId, fallback) =>
this.#resolveValueInput(node, pinId, fallback),
emitNextExec: (pinId, overrides = {}) => {
const nextIndentLevel = overrides.indentLevel ?? indentLevel;
const nextPath = overrides.path ?? path;
return this.#emitNextExec(node.id, pinId, nextIndentLevel, nextPath);
},
emitBranch: (pinId, overrides = {}) => {
const branchIndent = overrides.indentLevel ?? indentLevel;
const branchPath = overrides.path ?? path;
return this.#emitBranch(node.id, pinId, branchIndent, branchPath);
},
emitExecChain: (targetNodeId, targetIndentLevel, targetPath) =>
this.#emitExecChain(targetNodeId, targetIndentLevel, targetPath),
findExecTargets: (pinId) => this.#findExecTargets(node.id, pinId),
sanitizeIdentifier: (value) => this.#sanitizeIdentifier(String(value)),
sanitizeOperator: (value) => this.#sanitizeOperator(String(value)),
formatLiteral,
};
}
/**
* Dispatches code generation for a specific flow node.
*
* @param {BlueprintNode} node Target node.
* @param {number} indentLevel Indentation level.
* @param {Set<string>} path Cycle guard collection.
* @returns {Array<string>}
*/
#emitFlowNode(node, indentLevel, path) {
const behavior = this.registry.getBehavior(node.type);
if (behavior?.emitExec) {
return behavior.emitExec(
this.#createExecContext(node, indentLevel, path)
);
}
switch (node.type) {
case "event_start":
case "event_init":
case "event_update":
case "event_draw":
return this.#emitNextExec(node.id, "exec_out", indentLevel, path);
case "print":
return this.#emitPrint(node, indentLevel, path);
case "set_var":
return this.#emitSetVariable(node, indentLevel, path);
case "if":
return this.#emitIf(node, indentLevel, path);
case "for_loop":
return this.#emitForLoop(node, indentLevel, path);
case "sequence":
return this.#emitSequence(node, indentLevel, path);
case "call_custom_event":
return this.#emitCallCustomEvent(node, indentLevel, path);
default:
return [
`${this.#indent(indentLevel)}-- unsupported flow node: ${node.title}`,
];
}
}
/**
* Builds the value context shared with node behaviors.
*
* @param {BlueprintNode} node Active node reference.
* @param {string} pinId Value output pin.
* @returns {import('../nodes/nodeTypes.js').ValueContext & { pinId: string }}
*/
#createValueContext(node, pinId) {
return {
node,
pinId,
resolveValueInput: (inputPinId, fallback) =>
this.#resolveValueInput(node, inputPinId, fallback),
sanitizeIdentifier: (value) => this.#sanitizeIdentifier(String(value)),
sanitizeOperator: (value) => this.#sanitizeOperator(String(value)),
formatLiteral,
};
}
/**
* Emits Lua for a print node.
*
* @param {BlueprintNode} node Print node.
* @param {number} indentLevel Indent level.
* @param {Set<string>} path Cycle guard.
* @returns {Array<string>}
*/
#emitPrint(node, indentLevel, path) {
const text = this.#resolveValueInput(node, "msg", '""');
const x = this.#resolveValueInput(node, "x", "0");
const y = this.#resolveValueInput(node, "y", "0");
const color = this.#resolveValueInput(node, "color", "7");
const line = `${this.#indent(
indentLevel
)}print(${text}, ${x}, ${y}, ${color})`;
return [
line,
...this.#emitNextExec(node.id, "exec_out", indentLevel, path),
];
}
/**
* Emits Lua for a variable assignment node.
*
* @param {BlueprintNode} node Set variable node.
* @param {number} indentLevel Indent level.
* @param {Set<string>} path Cycle guard.
* @returns {Array<string>}
*/
#emitSetVariable(node, indentLevel, path) {
const name = this.#resolveWorkspaceVariableName(node);
const value = this.#resolveValueInput(node, "value", "nil");
const line = `${this.#indent(indentLevel)}${name} = ${value}`;
return [
line,
...this.#emitNextExec(node.id, "exec_out", indentLevel, path),
];
}
/**
* Emits Lua for a branch node.
*
* @param {BlueprintNode} node Branch node.
* @param {number} indentLevel Indent level.
* @param {Set<string>} path Cycle guard.
* @returns {Array<string>}
*/
#emitIf(node, indentLevel, path) {
const condition = this.#resolveValueInput(node, "condition", "false");
const lines = [`${this.#indent(indentLevel)}if ${condition} then`];
const thenLines = this.#emitBranch(
node.id,
"then",
indentLevel + 1,
new Set(path)
);
if (!thenLines.length) {
lines.push(`${this.#indent(indentLevel + 1)}-- then branch`);
} else {
lines.push(...thenLines);
}
const elseTargets = this.#findExecTargets(node.id, "else");
if (elseTargets.length) {
lines.push(`${this.#indent(indentLevel)}else`);
const elseLines = this.#emitBranch(
node.id,
"else",
indentLevel + 1,
new Set(path)
);
if (!elseLines.length) {
lines.push(`${this.#indent(indentLevel + 1)}-- else branch`);
} else {
lines.push(...elseLines);
}
}
lines.push(`${this.#indent(indentLevel)}end`);
return lines;
}
/**
* Emits Lua for a for-loop node.
*
* @param {BlueprintNode} node Loop node.
* @param {number} indentLevel Indent level.
* @param {Set<string>} path Cycle guard.
* @returns {Array<string>}
*/
#emitForLoop(node, indentLevel, path) {
const indexName = this.#sanitizeIdentifier(
String(node.properties.index ?? "i")
);
const startValue = this.#resolveValueInput(node, "start", "0");
const endValue = this.#resolveValueInput(node, "end", "0");
const stepValue = this.#resolveValueInput(node, "step", "1");
const lines = [
`${this.#indent(
indentLevel
)}for ${indexName} = ${startValue}, ${endValue}, ${stepValue} do`,
];
const loopLines = this.#emitBranch(
node.id,
"loop",
indentLevel + 1,
new Set(path)
);
if (!loopLines.length) {
lines.push(`${this.#indent(indentLevel + 1)}-- loop body`);
} else {
lines.push(...loopLines);
}
lines.push(`${this.#indent(indentLevel)}end`);
lines.push(...this.#emitBranch(node.id, "completed", indentLevel, path));
return lines;
}
/**
* Emits Lua for a sequence node, executing each branch in order.
*
* @param {BlueprintNode} node Sequence node.
* @param {number} indentLevel Indentation level.
* @param {Set<string>} path Cycle guard collection.
* @returns {Array<string>}
*/
#emitSequence(node, indentLevel, path) {
const lines = [];
node.outputs.forEach((pin) => {
const label = (pin.name ?? pin.id).toLowerCase();
lines.push(`${this.#indent(indentLevel)}-- sequence ${label}`);
const targets = this.#findExecTargets(node.id, pin.id);
if (!targets.length) {
return;
}
targets.forEach((target) => {
const branchPath = new Set(path);
lines.push(
...this.#emitExecChain(target.nodeId, indentLevel, branchPath)
);
});
});
return lines;
}
/**
* Emits Lua for a call custom event node.
*
* @param {BlueprintNode} node Call node reference.
* @param {number} indentLevel Indentation level.
* @param {Set<string>} path Cycle detection guard.
* @returns {Array<string>}
*/
#emitCallCustomEvent(node, indentLevel, path) {
const eventId =
typeof node.properties.eventId === "string"
? node.properties.eventId
: "";
const functionName = eventId
? this.customEventFunctionNames.get(eventId)
: null;
const lines = [];
if (functionName) {
const signature = this.customEventParameters.get(eventId) ?? [];
const argEntries = signature.map((entry) => {
const fallback = entry.optional
? "nil"
: defaultLiteralForKind(entry.kind);
const value = this.#resolveValueInput(
node,
entry.inputPinId,
fallback
);
return { value, optional: Boolean(entry.optional) };
});
while (
argEntries.length &&
argEntries[argEntries.length - 1].optional &&
argEntries[argEntries.length - 1].value === "nil"
) {
argEntries.pop();
}
const call = argEntries.length
? `${functionName}(${argEntries
.map((entry) => entry.value)
.join(", ")})`
: `${functionName}()`;
lines.push(`${this.#indent(indentLevel)}${call}`);
} else {
lines.push(`${this.#indent(indentLevel)}-- missing custom event target`);
}
lines.push(...this.#emitNextExec(node.id, "exec_out", indentLevel, path));
return lines;
}
/**
* Emits a branch for a given exec output pin.
*
* @param {string} nodeId Node identifier.
* @param {string} pinId Output pin identifier.
* @param {number} indentLevel Indent level.
* @param {Set<string>} path Cycle guard.
* @returns {Array<string>}
*/
#emitBranch(nodeId, pinId, indentLevel, path) {
const targets = this.#findExecTargets(nodeId, pinId);
if (!targets.length) {
return [];
}
const lines = [];
targets.forEach((target) => {
lines.push(...this.#emitExecChain(target.nodeId, indentLevel, path));
});
return lines;
}
/**
* Advances to the next node for sequential exec outputs.
*
* @param {string} nodeId Node identifier.
* @param {string} pinId Exec output pin identifier.
* @param {number} indentLevel Indent level.
* @param {Set<string>} path Cycle guard.
* @returns {Array<string>}
*/
#emitNextExec(nodeId, pinId, indentLevel, path) {
const targets = this.#findExecTargets(nodeId, pinId);
if (!targets.length) {
return [];
}
return this.#emitExecChain(targets[0].nodeId, indentLevel, path);
}
/**
* Retrieves execution targets for a given output pin.
*
* @param {string} nodeId Node identifier.
* @param {string} pinId Output pin identifier.
* @returns {Array<{nodeId:string,pinId:string}>}
*/
#findExecTargets(nodeId, pinId) {
return this.connections
.filter(
(connection) =>
connection.kind === "exec" &&
connection.from.nodeId === nodeId &&
connection.from.pinId === pinId
)
.map((connection) => connection.to);
}
/**
* Applies Lua type conversion when source and target types differ.
*
* @param {string} expression Source expression to convert.
* @param {string} fromType Source type identifier.
* @param {string} toType Target type identifier.
* @returns {string}
*/
#applyTypeConversion(expression, fromType, toType) {
if (fromType === toType || toType === "any" || fromType === "any") {
return expression;
}
const converter = getTypeConverter(fromType, toType);
if (!converter) {
return expression;
}
// Generate Lua code for type conversions
if ((fromType === "number" || fromType === "color") && toType === "boolean") {
return `(${expression}) ~= 0`;
}
if (fromType === "boolean" && toType === "number") {
return `((${expression}) and 1 or 0)`;
}
if (fromType === "boolean" && toType === "color") {
return `((${expression}) and 7 or 0)`;
}
if (fromType === "boolean" && toType === "string") {
return `((${expression}) and "true" or "false")`;
}
if (fromType === "number" && toType === "color") {
return `flr(${expression}) % 16`;
}
// No conversion needed for color to number (colors are numbers in PICO-8)
return expression;
}
/**
* Resolves the expression bound to an input pin.
*
* @param {BlueprintNode} node Parent node.
* @param {string} pinId Input pin identifier.
* @param {string} fallback Fallback literal when unconnected.
* @returns {string}
*/
#resolveValueInput(node, pinId, fallback) {
const connection = this.#getConnectionTo(node.id, pinId);
if (connection) {
const sourceExpression = this.#evaluateValue(connection.from.nodeId, connection.from.pinId);
const targetPin = this.#findPin(node, pinId);
const targetType = targetPin?.kind ?? "any";
// Get the actual source type, handling special cases like get_var
const sourceNode = this.nodes.get(connection.from.nodeId);
const sourceType = sourceNode
? this.#getOutputPinType(sourceNode, connection.from.pinId)
: connection.kind ?? "any";
return this.#applyTypeConversion(sourceExpression, sourceType, targetType);
}
const pin = this.#findPin(node, pinId);
const inlineKey = this.#inlinePropertyKey(pinId);
if (Object.prototype.hasOwnProperty.call(node.properties, inlineKey)) {
const inlineValue = node.properties[inlineKey];
if (inlineValue !== undefined && inlineValue !== null) {
return formatLiteral(pin?.kind ?? "any", inlineValue);
}
}
if (pin && pin.defaultValue !== undefined && pin.defaultValue !== null) {
return formatLiteral(pin.kind, pin.defaultValue);
}
return fallback;
}
/**
* Retrieves the pin descriptor for a node.
*
* @param {BlueprintNode} node Parent node.
* @param {string} pinId Pin identifier.
* @returns {import('../core/BlueprintNode.js').PinDescriptor | undefined}
*/
#findPin(node, pinId) {
return [...node.inputs, ...node.outputs].find((pin) => pin.id === pinId);
}
/**
* Locates the incoming connection for a specific pin.
*
* @param {string} nodeId Node identifier.
* @param {string} pinId Pin identifier.
* @returns {import('./Connection.js').Connection | undefined}
*/
#getConnectionTo(nodeId, pinId) {
return this.connections.find(
(connection) =>
connection.to.nodeId === nodeId && connection.to.pinId === pinId
);
}
/**
* Evaluates an expression produced by a value pin.
*
* @param {string} nodeId Node identifier.
* @param {string} pinId Output pin identifier.
* @returns {string}
*/
#evaluateValue(nodeId, pinId) {
const cacheKey = `${nodeId}:${pinId}`;
if (this.valueCache.has(cacheKey)) {
return this.valueCache.get(cacheKey);
}
if (this.valueStack.has(cacheKey)) {
return "nil";
}
this.valueStack.add(cacheKey);
const node = this.nodes.get(nodeId);
if (!node) {
this.valueStack.delete(cacheKey);
return "nil";
}
let expression;
const behavior = this.registry.getBehavior(node.type);
if (behavior?.evaluateValue) {
expression = behavior.evaluateValue(
this.#createValueContext(node, pinId)
);
}
if (expression === undefined) {
switch (node.type) {
case "get_var": {
const name = this.#resolveWorkspaceVariableName(node);
expression = name;
break;
}
case "custom_event": {
const parameterName = this.customEventParameterLookup.get(
`${node.id}:${pinId}`
);
expression = parameterName ?? "nil";
break;
}
case "add_number":
case "math_add": {
const a = this.#resolveValueInput(node, "a", "0");
const b = this.#resolveValueInput(node, "b", "0");
expression = `(${a}) + (${b})`;
break;
}
case "math_subtract": {
const a = this.#resolveValueInput(node, "a", "0");
const b = this.#resolveValueInput(node, "b", "0");
expression = `(${a}) - (${b})`;
break;
}
case "multiply_number":
case "math_multiply": {
const a = this.#resolveValueInput(node, "a", "1");
const b = this.#resolveValueInput(node, "b", "1");
expression = `(${a}) * (${b})`;
break;
}
case "math_divide": {
const a = this.#resolveValueInput(node, "a", "0");
const b = this.#resolveValueInput(node, "b", "1");
expression = `(${a}) / (${b})`;
break;
}
case "compare": {
const a = this.#resolveValueInput(node, "a", "0");
const b = this.#resolveValueInput(node, "b", "0");
const operator = this.#sanitizeOperator(
String(node.properties.operator ?? "==")
);
expression = `(${a}) ${operator} (${b})`;
break;
}
default: {
expression = "nil";
break;
}
}
}
this.valueStack.delete(cacheKey);
this.valueCache.set(cacheKey, expression);
return expression;
}
/**
* Sanitizes an identifier for Lua output.
*
* @param {string} name Input name.
* @returns {string}
*/
#sanitizeIdentifier(name) {
const trimmed = name.trim();
let sanitized = trimmed.replace(/[^a-zA-Z0-9_]/g, "_");
if (!sanitized.length) {
sanitized = "var";
}
if (!/^[a-zA-Z_]/.test(sanitized)) {
sanitized = `v_${sanitized}`;
}
return sanitized.toLowerCase();
}
/**
* Ensures comparison operators are valid for Lua.
*
* @param {string} operator Operator candidate.
* @returns {string}
*/
#sanitizeOperator(operator) {
const allowed = ["==", "!=", ">", "<", ">=", "<="];
if (allowed.includes(operator)) {
return operator === "!=" ? "~=" : operator;
}
return "==";
}
/**
* Determines the display name for a custom event node.
*
* @param {BlueprintNode} node Custom event node reference.
* @returns {string}
*/
#getCustomEventDisplayName(node) {
const raw =
typeof node.properties.name === "string"
? node.properties.name.trim()
: "";
return raw || "CustomEvent";
}
/**
* Builds a map of custom event node identifiers to Lua function names.
*
* @param {Array<BlueprintNode>} customEvents Custom event nodes to process.
* @returns {Map<string, string>}
*/
#buildCustomEventFunctionMap(customEvents) {
const map = new Map();
const usedNames = new Set();
customEvents.forEach((node) => {
const displayName = this.#getCustomEventDisplayName(node);
let baseIdentifier = this.#sanitizeIdentifier(displayName);
if (!baseIdentifier.startsWith("custom_")) {
baseIdentifier = `custom_${baseIdentifier}`;
}
let candidate = baseIdentifier;
let suffix = 1;
while (usedNames.has(candidate)) {
candidate = `${baseIdentifier}_${suffix}`;
suffix += 1;
}
usedNames.add(candidate);
map.set(node.id, candidate);
});
return map;
}
/**
* Builds metadata describing custom event parameter layouts.
*
* @param {Array<BlueprintNode>} customEvents Custom event nodes to process.
* @returns {{ parameterMap: Map<string, Array<{ id: string, name: string, luaName: string, outputPinId: string, inputPinId: string, kind: string, optional: boolean }>>, lookupMap: Map<string, string> }}
*/
#buildCustomEventParameterMap(customEvents) {
/** @type {Map<string, Array<{ id: string, name: string, luaName: string, outputPinId: string, inputPinId: string, kind: string, optional: boolean }>>} */
const parameterMap = new Map();
/** @type {Map<string, string>} */
const lookupMap = new Map();
customEvents.forEach((node) => {
const rawParameters = Array.isArray(node.properties.parameters)
? node.properties.parameters
: [];
const allowedKinds = new Set(["any", "number", "string", "boolean"]);
const usedNames = new Set();
/** @type {Array<{ id: string, name: string, luaName: string, outputPinId: string, inputPinId: string, kind: string }>} */
const entries = [];
rawParameters.forEach((parameter, index) => {
const rawName =
typeof parameter?.name === "string" ? parameter.name.trim() : "";
const displayName = rawName || `param${index + 1}`;
let luaName = this.#sanitizeIdentifier(displayName);
if (!luaName.length) {
luaName = `param${index + 1}`;
}
let candidate = luaName;
let suffix = 2;
while (usedNames.has(candidate)) {
candidate = `${luaName}_${suffix}`;
suffix += 1;
}
usedNames.add(candidate);
const rawId =
typeof parameter?.id === "string" ? parameter.id.trim() : "";
const parameterId =
rawId || `param_${String(index + 1).padStart(2, "0")}`;
const outputPinId = `param_${parameterId}`;
const inputPinId = `arg_${parameterId}`;
const rawKind =
typeof parameter?.type === "string" ? parameter.type : "any";
const kind = allowedKinds.has(rawKind) ? rawKind : "any";
const optional = Boolean(parameter?.optional);
entries.push({
id: parameterId,
name: displayName,
luaName: candidate,
outputPinId,
inputPinId,
kind,
optional,
});
lookupMap.set(`${node.id}:${outputPinId}`, candidate);
});
parameterMap.set(node.id, entries);
});
return { parameterMap, lookupMap };
}
/**
* Prepares global variable declarations for Lua output.
*
* @param {Array<{ id: string, name: string, type: string, defaultValue: unknown }>} variables Workspace variables.
* @returns {Array<string>}
*/
#prepareGlobalVariableDeclarations(variables) {
const declarations = [];
if (!Array.isArray(variables) || !variables.length) {
return declarations;
}
const usedNames = new Set();
variables.forEach((entry, index) => {
if (!entry) {
return;
}
const rawName =
typeof entry.name === "string" && entry.name.trim().length
? entry.name.trim()
: typeof entry.id === "string" && entry.id
? entry.id
: `var${index + 1}`;
let baseName = this.#sanitizeIdentifier(rawName);
if (!baseName.length) {
baseName = `var${index + 1}`;
}
let candidate = baseName;
let suffix = 2;
while (usedNames.has(candidate)) {
candidate = `${baseName}_${suffix}`;
suffix += 1;
}
usedNames.add(candidate);
if (typeof entry.id === "string" && entry.id.length) {
this.globalVariableNames.set(entry.id, candidate);
}
const literal = this.#formatVariableDefaultLiteral(entry);
declarations.push(`${candidate} = ${literal}`);
});
return declarations;
}
/**
* Formats a workspace variable default for Lua initialization.
*
* @param {{ type: string, defaultValue: unknown }} variable Variable descriptor.
* @returns {string}
*/
#formatVariableDefaultLiteral(variable) {
const type = this.#normalizeVariableType(variable?.type);
const value = variable?.defaultValue;
switch (type) {
case "number":
case "boolean":
case "string":
case "color":
return formatLiteral(type, value);
case "table": {
if (Array.isArray(value)) {
const fragments = value
.map((entry) => this.#formatTableDefaultEntry(entry))
.filter((fragment) => fragment.length);
if (!fragments.length) {
return "{}";
}
const rawCandidate =
value.length === 1 && typeof value[0]?.value === "string"
? value[0].value.trim()
: "";
if (
value.length === 1 &&
!value[0]?.key &&
rawCandidate.startsWith("{") &&
rawCandidate.endsWith("}")
) {
return rawCandidate;
}
const singleLine = fragments.join(", ");
if (singleLine.length <= 60 && !singleLine.includes("\n")) {
return `{ ${singleLine} }`;
}
return `{\n ${fragments.join(",\n ")}\n}`;
}
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length ? trimmed : "{}";
}
return "{}";
}
default: {
if (value === null || value === undefined) {
return "nil";
}
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : "nil";
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
return JSON.stringify(String(value));
}
}
}
/**
* Formats a table default entry into a Lua fragment.
*
* @param {{ key?: string, value?: string }} entry Table entry descriptor.
* @returns {string}
*/
#formatTableDefaultEntry(entry) {
if (!entry || typeof entry !== "object") {
return "";
}
const key =
typeof entry.key === "string" ? entry.key.trim() : "";
const value =
typeof entry.value === "string" ? entry.value.trim() : "";
if (!key.length) {
return value;
}
const formattedKey = this.#formatTableEntryKey(key);
const targetValue = value.length ? value : "nil";
return `${formattedKey} = ${targetValue}`;
}
/**
* Normalizes a table entry key for Lua output.
*
* @param {string} key Raw key value.
* @returns {string}
*/
#formatTableEntryKey(key) {
const trimmed = key.trim();
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
return trimmed;
}
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
return trimmed;
}
return `[${trimmed}]`;
}
/**
* Resolves the Lua identifier for a workspace variable node, honoring generator mappings.
*
* @param {BlueprintNode} node Blueprint node referencing a variable.
* @returns {string}
*/
#resolveWorkspaceVariableName(node) {
const rawId =
typeof node?.properties?.variableId === "string"
? node.properties.variableId
: "";
if (rawId) {
const mapped = this.globalVariableNames.get(rawId);
if (mapped) {
return mapped;
}
}
const fallback =
typeof node?.properties?.name === "string"
? node.properties.name
: "var";
return this.#sanitizeIdentifier(fallback);
}
/**
* Gets the type of a workspace variable by its ID.
*
* @param {string} variableId Variable identifier.
* @returns {string}
*/
#getVariableType(variableId) {
const variable = this.variables.find((v) => v.id === variableId);
return variable?.type ?? "any";
}
/**
* Gets the output type for a node's output pin.
*
* @param {BlueprintNode} node Source node.
* @param {string} pinId Output pin identifier.
* @returns {string}
*/
#getOutputPinType(node, pinId) {
// For get_var nodes, return the actual variable type
if (node.type === "get_var") {
const variableId = node.properties?.variableId;
if (variableId) {
return this.#getVariableType(variableId);
}
}
// Otherwise, use the pin's declared kind
const pin = this.#findPin(node, pinId);
return pin?.kind ?? "any";
}
/**
* Normalizes a variable type descriptor.
*
* @param {unknown} value Candidate type.
* @returns {string}
*/
#normalizeVariableType(value) {
return normalizeVariableType(value);
}
/**
* Returns indentation whitespace for the given level.
*
* @param {number} level Indent level.
* @returns {string}
*/
#indent(level) {
return this.indentUnit.repeat(level);
}
/**
* Generates the property key used for inline pin overrides.
*
* @param {string} pinId Pin identifier.
* @returns {string}
*/
#inlinePropertyKey(pinId) {
return `pin:${pinId}`;
}
}