rollup-plugin-unity-jslib
Version:
Rollup plugin for converting javascript library into Unity .jslib. file
657 lines (637 loc) • 26.2 kB
JavaScript
import ts from 'typescript';
class CsCode {
constructor() {
this.output = "";
}
addMethodHeader(code) {
this.output += code;
this.output += "\n";
}
addMethodBody(code) {
this.output += "{\n";
this.output += code;
this.output += "\n}\n\n";
}
beginMethodBody() {
this.output += "{\n";
}
endMethodBody() {
this.output += "}\n\n";
}
addCodeLine(code) {
this.output += code;
this.output += "\n";
}
addVariable(code) {
this.output += code;
this.output += "\n";
}
addNewLine() {
this.output += "\n";
}
toString() {
return this.output;
}
static addNamespace(code, namespace) {
code = `namespace ${namespace}\n{\n` + code;
code += "\n}";
return code;
}
}
var HookParameterType;
(function (HookParameterType) {
HookParameterType[HookParameterType["String"] = 0] = "String";
HookParameterType[HookParameterType["Number"] = 1] = "Number";
HookParameterType[HookParameterType["Object"] = 2] = "Object";
HookParameterType[HookParameterType["Void"] = 3] = "Void";
HookParameterType[HookParameterType["ByteArray"] = 4] = "ByteArray";
HookParameterType[HookParameterType["Boolean"] = 5] = "Boolean";
HookParameterType[HookParameterType["InitCallback"] = 6] = "InitCallback";
})(HookParameterType || (HookParameterType = {}));
const template$1 = `using UnityEngine;
using UnityEngine.Events;
using System.Runtime.InteropServices;
public class {{$className}} : MonoBehaviour
{
{{$methods}}
{{$callbacks}}
}
`;
class CsLibBuilder {
constructor(className, namespace, methodPrefix, useDynamicCall) {
this.className = className;
this.namespace = namespace;
this.methodPrefix = methodPrefix;
this.useDynamicCall = useDynamicCall;
}
buildCsClass(methods, calls) {
let cs = this.buildInitMethod();
for (let m of methods) {
const returnTypeStr = this.buildReturnType(m.returnType);
const funcParamsStr = this.buildFunctionParameters(m.parameters);
cs.addMethodHeader('[DllImport("__Internal")]');
cs.addMethodHeader(`private static extern ${returnTypeStr} ${this.methodPrefix}${m.name}${funcParamsStr};`);
}
cs.addNewLine();
if (!this.useDynamicCall) {
cs.addMethodHeader("#if !UNITY_EDITOR");
cs.addMethodHeader("private void Awake()");
if (this.useDynamicCall) {
cs.addMethodBody(`${this.methodPrefix}init(name, ${this.methodPrefix}OnDynamicCall);`);
}
else {
cs.addMethodBody(`${this.methodPrefix}init(name);`);
}
cs.addMethodHeader("#endif");
}
else {
const methodName = "Init";
const callbackFunc = `${this.methodPrefix}OnDynamicCall`;
cs.addMethodHeader(`public static void ${methodName}(string name)`);
cs.addMethodBody(`${this.methodPrefix}init(name, ${callbackFunc});`);
}
for (let m of methods) {
const methodName = m.name.charAt(0).toUpperCase() + m.name.slice(1);
const returnType = this.buildReturnType(m.returnType);
const functionParams = this.buildFunctionParameters(m.parameters);
const returnKeyword = m.returnType == HookParameterType.Void ? "" : "return ";
const modifier = this.useDynamicCall ? "public static" : "public";
cs.addMethodHeader(`${modifier} ${returnType} ${methodName}${functionParams}`);
cs.addMethodBody(`${returnKeyword}${this.methodPrefix}${m.name}${this.buildFunctionCall(m.parameters)};`);
}
let output = template$1
.replace("{{$className}}", this.className)
.replace("{{$methods}}", cs.toString())
.replace("{{$callbacks}}", this.buildUnityCallbacks(calls));
if (this.namespace) {
output = CsCode.addNamespace(output, this.namespace);
}
return output;
}
buildInitMethod() {
let output = new CsCode();
if (this.useDynamicCall) {
output.addMethodHeader('[DllImport("__Internal")]');
output.addMethodHeader(`private static extern int ${this.methodPrefix}init(string name, System.Action<byte[], int, byte[], int, byte[], int> onBytes);`);
return output;
}
output.addMethodHeader('[DllImport("__Internal")]');
output.addMethodHeader(`private static extern int ${this.methodPrefix}init(string name);`);
return output;
}
buildFunctionParameters(parameters) {
let parametersStr = "";
if (parameters) {
parametersStr = parameters.map((x) => this.buildParamCall(x)).join(", ");
}
return `(${parametersStr})`;
}
buildFunctionCall(parameters) {
let parametersStr = "";
const mapParameter = (param) => {
if (param.type == HookParameterType.ByteArray) {
return `${param.name}, ${param.name}Len`;
}
return param.name;
};
if (parameters) {
parametersStr = parameters.map((x) => mapParameter(x)).join(", ");
}
return `(${parametersStr})`;
}
buildParamCall(param) {
switch (param.type) {
case HookParameterType.Number:
return `int ${param.name}`;
case HookParameterType.String:
return `string ${param.name}`;
case HookParameterType.ByteArray:
return `byte[] ${param.name}, int ${param.name}Len`;
case HookParameterType.Boolean:
return `bool ${param.name}`;
}
return `string ${param.name}`;
}
buildReturnType(returnType) {
if (returnType == HookParameterType.Void) {
return "void";
}
if (returnType == HookParameterType.Number) {
return "int";
}
if (returnType == HookParameterType.ByteArray) {
return "byte[]";
}
if (returnType == HookParameterType.Boolean) {
return "bool";
}
return "string";
}
buildUnityCallbacks(calls) {
const output = new CsCode();
const eventsProduced = new Set();
const methodsProduced = new Set();
const dynCallProduced = new Set();
for (var c of calls) {
if (eventsProduced.has(c.methodName)) {
continue;
}
eventsProduced.add(c.methodName);
const hasParameters = c.parameterTypes.length > 0;
const parameters = c.parameterTypes.map((x) => this.buildReturnType(x)).join(", ");
const modifier = this.useDynamicCall ? "public static" : "public";
const eventStr = hasParameters ? `UnityEvent<${parameters}>` : `UnityEvent`;
output.addVariable(`${modifier} ${eventStr} ${c.methodName}Event = new ${eventStr}();`);
}
if (!this.useDynamicCall) {
for (var c of calls.filter((x) => !x.dynamicCall)) {
if (methodsProduced.has(c.methodName)) {
continue;
}
methodsProduced.add(c.methodName);
const methodParams = c.parameterTypes.map((x) => this.buildReturnType(x) + " arg").join(", ");
const execParams = c.parameterTypes.length > 0 ? "arg" : "";
const eventName = c.methodName + "Event";
output.addMethodHeader(`public void ${c.methodName}(${methodParams})`);
output.addMethodBody(`if (${eventName} != null) { ${eventName}.Invoke(${execParams}); }`);
}
}
if (this.useDynamicCall) {
output.addMethodHeader(`
[AOT.MonoPInvokeCallback(typeof(System.Action<byte[], int, byte[], int, byte[], int>))]
public static void ${this.methodPrefix}OnDynamicCall(
[MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.U1, SizeParamIndex = 1)] byte[] funcNameBuff, int funcNameLen,
[MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.U1, SizeParamIndex = 3)] byte[] payloadBuff, int payloadLen,
[MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.U1, SizeParamIndex = 5)] byte[] buffer, int len)`);
output.beginMethodBody();
output.addCodeLine("var funcName = System.Text.Encoding.UTF8.GetString(funcNameBuff, 0, funcNameLen - 1);");
output.addCodeLine("var payload = System.Text.Encoding.UTF8.GetString(payloadBuff, 0, payloadLen - 1);");
for (var c of calls.filter((x) => x.dynamicCall)) {
if (dynCallProduced.has(c.methodName)) {
continue;
}
dynCallProduced.add(c.methodName);
const eventName = c.methodName + "Event";
output.addCodeLine(`if(funcName == "${c.methodName}")`);
output.beginMethodBody();
output.addCodeLine(`if (${eventName} != null) { ${eventName}.Invoke(payload, buffer); }`);
output.addCodeLine("return;");
output.endMethodBody();
}
output.endMethodBody();
}
return output.toString();
}
}
class HooksParserResult {
constructor() {
this.methods = [];
}
}
class HooksParser {
constructor(logger) {
this.logger = logger;
}
parse(code) {
this._source = ts.createSourceFile("unityHooks.ts", code, ts.ScriptTarget.ES2015);
let classNode = this.findClassNode();
let result = new HooksParserResult();
if (classNode == null) {
console.log("No UnityHooks class found.");
return result;
}
this.logger.log(`Parsing method hooks`);
ts.forEachChild(classNode, (node) => {
var _a, _b;
if (ts.isMethodDeclaration(node)) {
const m = node;
const isStatic = (_a = ts.getModifiers(m)) === null || _a === void 0 ? void 0 : _a.some((x) => x.kind == ts.SyntaxKind.StaticKeyword);
const mName = (_b = m.name) === null || _b === void 0 ? void 0 : _b.escapedText;
if (!isStatic) {
console.warn("[toUnityJsLib] Skipped non static method - " + mName);
return;
}
this.logger.log(`- Hook found: ${mName}`);
const method = this.parseMethod(m);
result.methods.push(method);
}
});
return result;
}
findClassNode() {
let output = null;
ts.forEachChild(this._source, (node) => {
var _a;
if (ts.isClassDeclaration(node)) {
const c = node;
if (((_a = c.name) === null || _a === void 0 ? void 0 : _a.escapedText) == "UnityHooks") {
output = c;
}
}
});
return output;
}
parseMethod(m) {
var _a;
const name = m.name.escapedText;
const parameters = [];
for (var p of m.parameters) {
const param = p;
parameters.push({
name: (_a = param.name) === null || _a === void 0 ? void 0 : _a.escapedText,
type: this.parseParamType(param.type, HookParameterType.String),
});
}
const returnType = this.parseParamType(m.type, HookParameterType.Void);
return { name: name, parameters: parameters, returnType: returnType };
}
parseParamType(type, defaultType) {
if (!type) {
return defaultType;
}
this.logger.log(`-- Parameter kind: ${ts.SyntaxKind[type.kind]} (${type.kind})`);
const typeKind = type.kind;
switch (typeKind) {
case ts.SyntaxKind.NumberKeyword:
return HookParameterType.Number;
case ts.SyntaxKind.StringKeyword:
return HookParameterType.String;
case ts.SyntaxKind.ArrayType:
case ts.SyntaxKind.TupleType:
return HookParameterType.ByteArray;
case ts.SyntaxKind.BooleanKeyword:
return HookParameterType.Boolean;
}
return HookParameterType.Object;
}
}
const template = `
var __LIBNAME__ = {
$callbacks: {
onDynamicCall: {}
},
__METHODS__
};
autoAddDeps(__LIBNAME__, "$callbacks");
mergeInto(LibraryManager.library, __LIBNAME__);
`;
class JsLibBuilder {
constructor(logger, namespace, methodPrefix, useDynCalls = false) {
this.logger = logger;
this.libName = namespace;
this.namespace = "window." + namespace;
this.methodPrefix = methodPrefix;
this.useDyncCall = useDynCalls;
}
buildJsLib(code, methods) {
this.logger.log("Building .jslib file:");
let methodsStr = this.buildInitMethod(code);
for (let m of methods) {
this.logger.log(`- Building Method ${m.name}`);
const engineCall = `${this.namespace}.${m.name}${this.buildEngineCallParameters(m.parameters)};`;
methodsStr += `${this.methodPrefix}${m.name}: function(${this.buildFunctionParameters(m.parameters)}) {\n`;
methodsStr += this.buildFunctionCall(engineCall, m.returnType);
methodsStr += "\n},\n";
}
let output = template.replace(/__LIBNAME__/g, `${this.libName}Module`).replace("__METHODS__", methodsStr);
return output;
}
buildInitMethod(code) {
let uCallFuncCode = `function UCALL(funcName, arg) {
if(!window._unityInstance){
console.log("Unity game instance could not be found. Please modify your index.html template.");
return;
}
window._unityInstance.SendMessage(gameObjName, funcName, arg);
}`;
if (this.useDyncCall) {
uCallFuncCode += `
\n
function DYNCALL(funcName, payload, data) {
if (!(payload instanceof String)) {
payload = JSON.stringify(payload);
}
if(!data) {
data = new Uint8Array();
}
const payloadBufferSize = lengthBytesUTF8(payload) + 1;
const payloadBuffer = _malloc(payloadBufferSize);
stringToUTF8(payload, payloadBuffer, payloadBufferSize);
const funcNameBufferSize = lengthBytesUTF8(funcName) + 1;
const funcNameBuffer = _malloc(funcNameBufferSize);
stringToUTF8(funcName, funcNameBuffer, funcNameBufferSize);
const buffer = _malloc(data.length * data.BYTES_PER_ELEMENT);
HEAPU8.set(data, buffer);
Module.dynCall_viiiiii(
callbacks.onDynamicCall,
funcNameBuffer,
funcNameBufferSize,
payloadBuffer,
payloadBufferSize,
buffer,
data.length
);
_free(payloadBuffer);
_free(funcNameBuffer);
_free(buffer);
}
`;
}
code = code.replace("'use strict';", "'use strict';\n\n" + uCallFuncCode);
let initCode = `
${this.methodPrefix}init: function(gameObjNameStr) {
const gameObjName = UTF8ToString(gameObjNameStr);
${this.namespace} = ${code}
},
`;
if (this.useDyncCall) {
initCode = `
${this.methodPrefix}init: function(gameObjNameStr, onDynamicCall) {
const gameObjName = UTF8ToString(gameObjNameStr);
callbacks.onDynamicCall = onDynamicCall;
${this.namespace} = ${code}
},
`;
}
return initCode;
}
buildFunctionParameters(parameters) {
let parametersStr = "";
if (parameters) {
parametersStr = parameters.map((x) => this.buildFunctionParam(x)).join(", ");
}
return `${parametersStr}`;
}
buildFunctionParam(param) {
if (param.type == HookParameterType.ByteArray) {
return `${param.name}, ${param.name}Len`;
}
return param.name;
}
buildEngineCallParameters(parameters) {
let parametersStr = "";
if (parameters) {
parametersStr = parameters.map((x) => this.buildUnityVarCall(x)).join(", ");
}
return `(${parametersStr})`;
}
buildUnityVarCall(param) {
switch (param.type) {
case HookParameterType.Number:
return param.name;
case HookParameterType.String:
return `UTF8ToString(${param.name})`;
case HookParameterType.ByteArray:
return `HEAP8.subarray(${param.name}, ${param.name} + ${param.name}Len)`;
}
return `JSON.parse(UTF8ToString(${param.name}))`;
}
buildFunctionCall(callStr, returnType) {
if (returnType == HookParameterType.Void) {
return callStr;
}
if (returnType == HookParameterType.Number) {
return `return ${callStr}`;
}
let output = `var result = ${callStr}\n`;
if (returnType == HookParameterType.Object) {
output += `result = JSON.stringify(result);\n`;
}
output += "var bs = lengthBytesUTF8(result);\n";
output += "var buff = _malloc(bs);\n";
output += "stringToUTF8(result, buff, bs);\n";
output += "return buff;";
return output;
}
}
class EmptyLogger {
log(_) { }
}
class DebugLogger {
log(message) {
console.log(message);
}
}
class UnityCallParser {
constructor(useDynamicCalls) {
this._useDynCalls = useDynamicCalls;
}
collectUnityCalls(code) {
const output = [];
const match = code.match(/UCALL\(([^)]+)\)/g);
if (match) {
for (const m of match) {
const calls = this.parseCallCode(m);
for (const c of calls) {
output.push(c);
}
}
}
const dynCallMatch = code.match(/DYNCALL\(([^)]+)\)/g);
if (this._useDynCalls && dynCallMatch) {
for (const m of dynCallMatch) {
const calls = this.parseCallCode(m, true);
for (const c of calls) {
output.push(c);
}
}
}
this.validateCallList(output);
return output;
}
parseCallCode(funcCode, dynamicCall = false) {
const s = ts.createSourceFile("", funcCode, ts.ScriptTarget.ES2015);
const calls = [];
s.forEachChild((x) => {
const fd = x;
if (!fd) {
return;
}
const exp = fd.expression;
if (!exp) {
return;
}
const call = dynamicCall ? this.parseDynCallExpression(funcCode, exp) : this.parseUCallExpression(funcCode, exp);
if (!call) {
return;
}
calls.push(call);
});
return calls;
}
parseUCallExpression(code, exp) {
const call = {
methodName: "",
parameterTypes: [],
dynamicCall: false,
};
if (exp.arguments.length < 1) {
throw new Error("Invalid UCALL execution. Correc call: UCALL('FuncName', 'optionalVar'). \n" + code);
}
if (exp.arguments.length > 2) {
throw new Error("Invalid UCALL execution. Correc call: UCALL('FuncName', 'optionalVar'). \n" + code);
}
const funcNameArg = exp.arguments[0];
if (funcNameArg.kind != ts.SyntaxKind.StringLiteral) {
throw new Error("Invalid UCALL execution. Correc call: UCALL('FuncName', 'optionalVar'). \n" + code);
}
call.methodName = funcNameArg.text;
if (exp.arguments.length > 1) {
const variableArg = exp.arguments[1];
call.parameterTypes.push(this.toArgumentType(variableArg.kind));
}
return call;
}
parseDynCallExpression(code, exp) {
const call = {
methodName: "",
parameterTypes: [],
dynamicCall: true,
};
if (exp.arguments.length < 1) {
throw new Error("Invalid DYNCALL execution. Correc call: DYNCALL('FuncName', 'payload', 'buffer'). \n" + code);
}
if (exp.arguments.length > 3) {
throw new Error("Invalid DYNCALL execution. Correc call: DYNCALL('FuncName', 'payload', 'buffer'). \n" + code);
}
const funcNameArg = exp.arguments[0];
if (funcNameArg.kind != ts.SyntaxKind.StringLiteral) {
throw new Error("Invalid DYNCALL execution. Correc call: DYNCALL('FuncName', 'payload', 'buffer'). \n" + code);
}
call.methodName = funcNameArg.text;
if (exp.arguments.length > 1) {
const variableArg = exp.arguments[1];
call.parameterTypes.push(this.toArgumentType(variableArg.kind));
call.parameterTypes.push(HookParameterType.ByteArray);
}
return call;
}
toArgumentType(tsKind) {
switch (tsKind) {
case ts.SyntaxKind.StringLiteral:
case ts.SyntaxKind.Identifier:
case ts.SyntaxKind.CallExpression:
return HookParameterType.String;
case ts.SyntaxKind.NumericLiteral:
return HookParameterType.Number;
}
throw new Error("Unhandler UCALL argument type " + tsKind);
}
validateCallList(calls) {
for (const c of calls) {
var sameMethodCalls = calls.filter((x) => x.methodName == c.methodName);
if (sameMethodCalls.filter((x) => x.parameterTypes.length != c.parameterTypes.length).length > 0) {
throw new Error(`UCALL(${c.methodName}) method was called with different parameters count.`);
}
}
}
}
var parserResult;
var jsLibRootClassFound = false;
function toUnityJsLib(options) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
options = options !== null && options !== void 0 ? options : { useDynamicCall: false };
(_a = options.csOutput) !== null && _a !== void 0 ? _a : { fileName: "UnityJsLibHooks", namespace: null };
(_b = options.jsLibOutput) !== null && _b !== void 0 ? _b : { fileName: "index", windowObjName: "_skJsLibEngine" };
const jsLibFileName = (_d = (_c = options.jsLibOutput) === null || _c === void 0 ? void 0 : _c.fileName) !== null && _d !== void 0 ? _d : "index";
const jsNamespace = (_f = (_e = options.jsLibOutput) === null || _e === void 0 ? void 0 : _e.windowObjName) !== null && _f !== void 0 ? _f : "_skJsLibEngine";
const csFileName = (_h = (_g = options.csOutput) === null || _g === void 0 ? void 0 : _g.fileName) !== null && _h !== void 0 ? _h : "UnityJsLibHooks";
const csNamespace = (_k = (_j = options.csOutput) === null || _j === void 0 ? void 0 : _j.namespace) !== null && _k !== void 0 ? _k : null;
const rootClassName = (_l = options.rootClassName) !== null && _l !== void 0 ? _l : "UnityHooks";
const methodPrefix = (_m = options.methodPrefix) !== null && _m !== void 0 ? _m : "SK_";
const bundleFileName = (_o = options.bundleFileName) !== null && _o !== void 0 ? _o : "index.js";
const useDynamicCall = options.useDynamicCall;
const logger = options.debug ? new DebugLogger() : new EmptyLogger();
const unityCalls = [];
const classHeader = `class ${rootClassName}`;
return {
name: "toJsLib",
async generateBundle(_, bundle) {
if (!jsLibRootClassFound) {
throw new Error(`[toUnityJs] Default class not found. Ensure that "export default ${classHeader}" exists."`);
}
if (!parserResult || parserResult.methods.length == 0) {
throw new Error(`[toUnityJs] Not exportable methods found. Ensure that ${classHeader}" contains static methods."`);
}
let code = bundle[bundleFileName].code;
let builder = new JsLibBuilder(logger, jsNamespace, methodPrefix, useDynamicCall);
code = builder.buildJsLib(code, parserResult.methods);
logger.log(`Producing output file ${jsLibFileName}.jslib`);
this.emitFile({
type: "asset",
fileName: `${jsLibFileName}.jslib`,
source: code,
});
logger.log(`Producing output file ${csFileName}.cs`);
let csBuilder = new CsLibBuilder(csFileName, csNamespace, methodPrefix, useDynamicCall);
this.emitFile({
type: "asset",
fileName: `${csFileName}.cs`,
source: csBuilder.buildCsClass(parserResult.methods, unityCalls),
});
},
async transform(code, id) {
const ucallParser = new UnityCallParser(useDynamicCall);
if (code.includes(classHeader)) {
logger.log(`Parsing "${id}" root file`);
const parser = new HooksParser(logger);
parserResult = parser.parse(code);
jsLibRootClassFound = true;
}
const ucalls = ucallParser.collectUnityCalls(code);
for (const c of ucalls) {
unityCalls.push(c);
logger.log(`Unity Call found: ${c.methodName} - DYNCALL: ${c.dynamicCall}`);
}
const result = {
code: code,
};
return result;
},
buildStart() {
jsLibRootClassFound = false;
parserResult = null;
},
};
}
export { toUnityJsLib as default };