UNPKG

rollup-plugin-unity-jslib

Version:

Rollup plugin for converting javascript library into Unity .jslib. file

657 lines (637 loc) 26.2 kB
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 };