UNPKG

mdx-m3-viewer

Version:

A browser WebGL model viewer. Mainly focused on models of the games Warcraft 3 and Starcraft 2.

1,073 lines (897 loc) 36.3 kB
import Trigger from '../parsers/w3x/wtg/trigger'; import ECA from '../parsers/w3x/wtg/eca'; import Parameter from '../parsers/w3x/wtg/parameter'; import SubParameters from '../parsers/w3x/wtg/subparameters'; import CustomTextTrigger from '../parsers/w3x/wct/customtexttrigger'; /** * The data needed to convert one map. */ class WeuConverterData { /** * @param {TriggerData} triggerData */ constructor(triggerData) { /** @member {TriggerData} */ this.triggerData = triggerData; /** @member {?War3MapWts} */ this.stringTable = null; /** @member {Array<Trigger|ECA|Parameter|SubParameters>} */ this.stack = []; /** @member {Object<string, number>} */ this.generatedNames = {}; /** @member {Array<string>} */ this.generatedFunctions = []; /** @member {Object<string, boolean>} */ this.preplacedObjects = {}; /** @member {Object} */ this.output = {ok: false, error: '', changes: []}; } } /** * Every time a reference to a preplaced object is encountered while testing the GUI, this will be called with isGUI being true. * Every time a reference to a preplaced object is converted to custom script, this will be called with isGUI being false. * This is used to track references that existed in GUI before the conversion, but that will be only in custom scripts afterwards. * References that are lost due to the conversion are then added in a new trigger called PreplacedObjectReferences. * * @param {WeuConverterData} data * @param {string} name * @param {boolean} isGUI */ function updateGUIRef(data, name, isGUI) { // For now track only units and destructibles. // Not sure what else needs tracking. if (name.startsWith('gg_unit') || name.startsWith('gg_dest')) { let preplacedObjects = data.preplacedObjects; // If the reference is already known to be used by GUI, no need to do anything. if (!preplacedObjects[name]) { preplacedObjects[name] = isGUI; } } } /** * @param {Array<Trigger|ECA|Parameter|SubParameters>} stack * @return {string} */ function stackToString(stack) { return stack.map((object) => { if (object instanceof Trigger) { return `Trigger ${object.name}`; } else if (object instanceof ECA || object instanceof SubParameters) { return object.name; } else if (object instanceof Parameter) { return object.value; } }).reverse().join(' > '); } /** * Creates a new Custom Script or comment ECA with the given data. * * @param {string} data * @param {boolean} isComment * @return {ECA} */ function createCustomScriptOrCommentECA(data, isComment) { let eca = new ECA(); eca.type = 2; // Action if (isComment) { eca.name = 'CommentString'; } else { eca.name = 'CustomScriptCode'; } eca.isEnabled = 1; let parameter = new Parameter(); parameter.type = 3; // String parameter.value = data; eca.parameters[0] = parameter; return eca; } /** * Creates a new Custom Script ECA with the given script. * * @param {string} script * @return {ECA} */ function createCustomScriptECA(script) { return createCustomScriptOrCommentECA(script, false); } // /** // * Creates a new comment ECA with the given comment. // * // * @param {string} comment // * @return {ECA} // */ // function createCommentECA(comment) { // return createCustomScriptOrCommentECA(comment, true); // } /** * @param {WeuConverterData} data * @param {ECA|SubParameters} object * @return {string} */ function generateCallbackName(data, object) { if (data.generatedNames[object.name] === undefined) { data.generatedNames[object.name] = 0; } let trigger = data.stack[data.stack.length - 1]; // Convert non-ASCII characters to underlines, for locales other than en. let name = trigger.name.split('').map((value) => value.charCodeAt(0) > 127 ? '_' : value).join(''); return `Trig_${name.replace(/\s/g, '_')}_Func${data.generatedNames[object.name]++}_${object.name}`.replace(/_+/g, '_'); } /** * Converts an ECA or SubParameters to an array of custom script ECAs. * Also creates callbacks when needed, which are added to the map header. * * @param {WeuConverterData} data * @param {ECA|SubParameters} object * @return {string} */ function convertFunctionCallToCustomScript(data, object) { let ecas = []; let parameters = object.parameters; let {args, scriptName} = data.triggerData.getFunction(object.type, object.name); let name = object.name; let argCount = args.length; let isCode = false; let isBoolexpr = false; scriptName = scriptName || object.name; if (argCount) { let lastArg = args[argCount - 1]; if (lastArg === 'code') { isCode = true; } else if (lastArg === 'boolexpr') { isBoolexpr = true; } } // IfThenElse and other control flow "functions" must come before the generic code/boolexpr callback handling, since they don't follow the same rules. if (name === 'IfThenElse') { ecas.push(createCustomScriptECA(`if ${convertParameterToCustomScript(data, parameters[0], args[0])} then`)); ecas.push(createCustomScriptECA(`call ${convertParameterToCustomScript(data, parameters[1], args[1])}`)); ecas.push(createCustomScriptECA('else')); ecas.push(createCustomScriptECA(`call ${convertParameterToCustomScript(data, parameters[2], args[2])}`)); ecas.push(createCustomScriptECA('endif')); } else if (name === 'OrMultiple') { ecas[0] = createCustomScriptECA(object.ecas.slice().map((eca) => convertFunctionCallToCustomScript(data, eca).map((eca) => eca.parameters[0].value)).join(' or ')); } else if (name === 'ForLoopAMultiple') { ecas.push(createCustomScriptECA(`set bj_forLoopAIndex = ${convertParameterToCustomScript(data, parameters[0], args[0])}`)); ecas.push(createCustomScriptECA(`set bj_forLoopAIndexEnd = ${convertParameterToCustomScript(data, parameters[1], args[1])}`)); ecas.push(createCustomScriptECA('loop')); ecas.push(createCustomScriptECA('exitwhen bj_forLoopAIndex > bj_forLoopAIndexEnd')); for (let action of object.ecas) { let replacements = convertFunctionCallToCustomScript(data, action); for (let replacement of replacements) { ecas.push(createCustomScriptECA(`${replacement.parameters[0].value}`)); } } ecas.push(createCustomScriptECA('endloop')); } else if (name === 'IfThenElseMultiple') { let condition; let thenActions = []; let elseActions = []; for (let eca of object.ecas) { if (eca.group === 0) { condition = eca; } else if (eca.group === 1) { thenActions.push(eca); } else if (eca.group === 2) { elseActions.push(eca); } } ecas.push(createCustomScriptECA(`if ${convertFunctionCallToCustomScript(data, condition)[0].parameters[0].value} then`)); for (let action of thenActions) { let replacements = convertFunctionCallToCustomScript(data, action); for (let replacement of replacements) { ecas.push(createCustomScriptECA(`${replacement.parameters[0].value}`)); } } if (elseActions.length) { ecas.push(createCustomScriptECA('else')); for (let action of elseActions) { let replacements = convertFunctionCallToCustomScript(data, action); for (let replacement of replacements) { ecas.push(createCustomScriptECA(`${replacement.parameters[0].value}`)); } } } ecas.push(createCustomScriptECA('endif')); } else if (isCode || isBoolexpr) { let callbackName = generateCallbackName(data, object); let call = `function ${callbackName}`; let returnType = 'nothing'; let callOrReturn = 'call'; let lastParam = parameters.length - 1; let isMultiple = object.name.endsWith('Multiple'); if (isBoolexpr) { call = `Filter(${call})`; returnType = 'boolean'; callOrReturn = 'return'; } if (isMultiple) { lastParam = parameters.length; } let callParams = [...parameters.slice(0, lastParam).map((value, index) => convertParameterToCustomScript(data, value, args[index])), call]; if (object instanceof ECA) { ecas.push(createCustomScriptECA(`call ${scriptName}(${callParams.join(', ')})`)); } else { ecas.push(createCustomScriptECA(`${scriptName}(${callParams.join(', ')})`)); } let callback; let body = ''; if (isMultiple) { body = object.ecas.map((eca) => convertFunctionCallToCustomScript(data, eca).map((customScript) => customScript.parameters[0].value).join('\n')).join('\n'); } else { body = `${callOrReturn} ${convertParameterToCustomScript(data, parameters[lastParam], args[lastParam])}`; } callback = `function ${callbackName} takes nothing returns ${returnType}\n${body}\nendfunction`; data.generatedFunctions.push(callback); data.output.changes.push({type: 'generatedfunction', stack: stackToString(data.stack), data: callback}); } else if (name === 'CustomScriptCode') { ecas[0] = object; } else if (name === 'SetVariable') { ecas.push(createCustomScriptECA(`set ${convertParameterToCustomScript(data, parameters[0], args[0])} = ${convertParameterToCustomScript(data, parameters[1], args[1])}`)); } else if (name === 'OperatorString') { // String concat ecas.push(createCustomScriptECA(`${convertParameterToCustomScript(data, parameters[0], args[0])} + ${convertParameterToCustomScript(data, parameters[1], args[1])}`)); } else if (name.startsWith('Operator')) { // All other operators? ecas.push(createCustomScriptECA(`${convertParameterToCustomScript(data, parameters[0], args[0])} ${convertParameterToCustomScript(data, parameters[1], args[1])} ${convertParameterToCustomScript(data, parameters[2], args[2])}`)); } else if (object instanceof ECA) { ecas.push(createCustomScriptECA(`call ${scriptName}(${parameters.map((value, index) => convertParameterToCustomScript(data, value, args[index])).join(', ')})`)); } else if (object instanceof SubParameters) { ecas.push(createCustomScriptECA(`${scriptName}(${parameters.map((value, index) => convertParameterToCustomScript(data, value, args[index])).join(', ')})`)); } return ecas; } /** * Converts a parameter to custom script. * * @param {WeuConverterData} data * @param {Parameter} parameter * @param {string} dataType * @return {string} */ function convertParameterToCustomScript(data, parameter, dataType) { let type = parameter.type; let value = parameter.value; // 0: preset // 1: variable // 2: function // 3: literal // -1: invalid if (type === 0) { return data.triggerData.getPreset(value); } else if (type === 1) { if (value.startsWith('gg_')) { // Used to track global generated variables and their status. updateGUIRef(data, value, false); return value; } else { if (parameter.isArray) { return `udg_${value}[${convertParameterToCustomScript(data, parameter.arrayIndex, 'integer')}]`; } else { return `udg_${value}`; } } } else if (parameter.type === 2) { return convertFunctionCallToCustomScript(data, parameter.subParameters)[0].parameters[0].value; } else if (parameter.type === 3) { let baseType = data.triggerData.getBaseType(dataType); // "value" // scriptcode needs to be converted as-is, and doesn't need quotes. if (baseType === 'string' && dataType !== 'scriptcode') { // Inline string table entries. if (value.startsWith('TRIGSTR')) { let index = parseInt(value.slice(8)); let string = data.stringTable.stringMap.get(index).replace(/\n/g, '\\n'); let callbackName = `StringTable${index}`; let callback = `function ${callbackName} takes nothing returns string\nreturn "${string}"\nendfunction`; data.generatedFunctions.push(callback); data.output.changes.push({type: 'generatedstringtable', stack: stackToString(data.stack), data: {value, callback}}); return `${callbackName}()`; } return `"${value.replace(/\\/g, '\\\\')}"`; } // 'value' if (baseType === 'integer' && isNaN(value)) { return `'${value}'`; } // value return value; } else { return ''; } } /** * Given a call to IsUnitOwnedByPlayer that is inside an OperatorCompareBoolean, replace it with GetOwningPlayer in an OperatorComparePlayer. * This will only happen if the boolean comparison was compared against "true" or "false". * If the comparison term is more complex, the replacement will be skipped. * * @param {SubParameters} object * @return {boolean} */ function replaceIsUnitOwned(object) { let trueOrFalse = object.parameters[2].value; if (trueOrFalse !== 'true' && trueOrFalse !== 'false') { return false; } object.name = 'OperatorComparePlayer'; let unit = object.parameters[0].subParameters.parameters[0]; let otherPlayer = object.parameters[0].subParameters.parameters[1]; // Change IsUnitOwnedByPlayer(unit, otherPlayer) to GetOwningPlayer(unit) object.parameters[0].value = 'GetOwningPlayer'; object.parameters[0].subParameters.name = 'GetOwningPlayer'; object.parameters[0].subParameters.parameters.length = 1; object.parameters[0].subParameters.parameters[0] = unit; // Equal or not equal if (trueOrFalse === 'true') { object.parameters[1].value = 'OperatorEqualENE'; } else { object.parameters[1].value = 'OperatorNotEqualENE'; } // Change false/true to otherPlayer object.parameters[2] = otherPlayer; return true; } /** * Given a call to IsUnitInRange that is inside an OperatorCompareBoolean, replace it with DistanceBetweenPoints in an OperatorCompareReal. * This will only happen if the boolean comparison was compared against "true" or "false". * If the comparison term is more complex, the replacement will be skipped. * * @param {ECA|SubParameters} object * @return {boolean} */ function replaceIsUnitInRange(object) { let trueOrFalse = object.parameters[2].value; if (trueOrFalse !== 'true' && trueOrFalse !== 'false') { return false; } object.name = 'OperatorCompareReal'; let unit = object.parameters[0].subParameters.parameters[0]; let otherUnit = object.parameters[0].subParameters.parameters[1]; let range = object.parameters[0].subParameters.parameters[2]; object.parameters[0].value = 'DistanceBetweenPoints'; object.parameters[0].subParameters.name = 'DistanceBetweenPoints'; let getUnitLoc1 = new Parameter(); getUnitLoc1.type = 2; // function getUnitLoc1.value = 'GetUnitLoc'; getUnitLoc1.subParameters = new SubParameters(); getUnitLoc1.subParameters.beginParameters = 1; getUnitLoc1.subParameters.type = 3; getUnitLoc1.subParameters.name = 'GetUnitLoc'; getUnitLoc1.subParameters.parameters[0] = unit; let getUnitLoc2 = new Parameter(); getUnitLoc2.type = 2; // function getUnitLoc2.value = 'GetUnitLoc'; getUnitLoc2.subParameters = new SubParameters(); getUnitLoc2.subParameters.beginParameters = 1; getUnitLoc2.subParameters.type = 3; getUnitLoc2.subParameters.name = 'GetUnitLoc'; getUnitLoc2.subParameters.parameters[0] = otherUnit; let operator = new Parameter(); operator.type = 0; // preset if (trueOrFalse === 'true') { operator.value = 'OperatorLessEq'; } else { operator.value = 'OperatorGreater'; } object.parameters[0].subParameters.parameters.length = 0; object.parameters[0].subParameters.parameters[0] = getUnitLoc1; object.parameters[0].subParameters.parameters[1] = getUnitLoc2; object.parameters[1] = operator; object.parameters[2] = range; return true; } /** * Given a call to SetHeroStr, SetHeroAgi, or SetHeroInt, replace it with ModifyHeroStat. * This will only happen if the change is permanent, since ModifyHeroStat is hardcoded for permanent changes. * * @param {ECA|SubParameters} object * @return {boolean} */ function replaceSetHeroStat(object) { // If it's not permanent, can't change to GUI. if (object.parameters[2].value !== 'PermanentPerm') { return false; } let name = object.name; let whichHero = object.parameters[0]; let value = object.parameters[1]; object.name = 'ModifyHeroStat'; let whichStat = new Parameter(); whichStat.type = 0; // preset whichStat.value = `HeroStat${name.slice(-3)}`; let modifyMethod = new Parameter(); modifyMethod.type = 0; // preset modifyMethod.value = 'ModifyMethodSet'; object.parameters[0] = whichStat; object.parameters[1] = whichHero; object.parameters[2] = modifyMethod; object.parameters[3] = value; return true; } /** * Given a TriggerRegisterUnitStateEvent call, replace it with TriggerRegisterUnitLifeEvent/TriggerRegisterUnitManaEvent. * WEU has both event and action variations for TriggerRegisterUnitStateEvent. * This handles only the event, while the actions will be converted to custom script. * * @param {ECA} object * @return {boolean} */ function replaceTriggerRegisterUnitStateEvent(object) { let state = object.parameters[1].value; if (state === 'UnitStateLife') { object.name = 'TriggerRegisterUnitLifeEvent'; } else if (state === 'UnitStateMana') { object.name = 'TriggerRegisterUnitManaEvent'; } else { throw new Error(`Trying to replace unit state "${state}"`); } // Remove the state parameter. object.parameters.splice(1, 1); return true; } /** * Given a IsUnitType call inside an OperatorCompareBoolean, with the type being UNIT_TYPE_DEAD, replace it with IsUnitDeadBJ. * This may or may not have side effects on the game in some situations. Warcraft 3 is weird. * That is the reasoning behind not changing this for non-conditions, since they can be converted to custom scripts. * * @param {ECA} object * @return {boolean} */ function replaceIsUnitType(object) { let parameter = object.parameters[0]; let subParameters = parameter.subParameters; let unitType = subParameters.parameters[1].value; // Exposed in WEU and YDWE as UnitTypedead (with the typo). if (unitType.toLowerCase() !== 'unittypedead') { return false; } parameter.value = 'IsUnitDeadBJ'; subParameters.name = 'IsUnitDeadBJ'; subParameters.parameters.length = 1; return true; } /** * Given a IsUnitRace call inside an OperatorCompareBoolean, replace it with GetUnitRace inside an OperatorCompareRace. * This will only happen for a simple true/false comparison. * * @param {ECA} object * @return {boolean} */ function replaceIsUnitRace(object) { let trueOrFalse = object.parameters[2].value; if (trueOrFalse !== 'true' && trueOrFalse !== 'false') { return false; } object.name = 'OperatorCompareRace'; let operator = object.parameters[1].value; let race = object.parameters[0].subParameters.parameters[1]; // IsUnitRace -> GetUnitRace. object.parameters[0].value = 'GetUnitRace'; object.parameters[0].subParameters.name = 'GetUnitRace'; // Remove the race from GetUnitRace. object.parameters[0].subParameters.parameters.length = 1; // And add it instead to OperatorCompareRace. object.parameters[2] = race; let isEqual = operator === 'OperatorEqualENE'; let isTrue = trueOrFalse === 'true'; // Essentially a XOR between the booleans. if (isEqual === isTrue) { object.parameters[1].value = 'OperatorEqualENE'; } else { object.parameters[1].value = 'OperatorNotEqualENE'; } return true; } /** * The Warcraft 3 1.29 PTR introduced many new functions. * A lot were later prepended with Blz to avoid name clashes with user functions. * This returns whether the given name matches one of these functions. * * @param {string} name * @return {boolean} */ function isBlzNeeded(name) { return name === 'GetTriggerPlayerMouseX' || name === 'GetTriggerPlayerMouseY' || name === 'GetTriggerPlayerMousePosition' || name === 'GetTriggerPlayerMouseButton' || name === 'SetAbilityTooltip' || name === 'SetAbilityExtendedTooltip' || name === 'SetAbilityResearchTooltip' || name === 'SetAbilityResearchExtendedTooltip' || name === 'GetAbilityTooltip' || name === 'GetAbilityExtendedTooltip' || name === 'GetAbilityResearchTooltip' || name === 'GetAbilityResearchExtendedTooltip' || name === 'SetAbilityIcon' || name === 'GetAbilityIcon' || name === 'GetAbilityPosX' || name === 'GetAbilityPosY' || name === 'SetAbilityPosX' || name === 'SetAbilityPosY' || name === 'GetUnitMaxHP' || name === 'SetUnitMaxHP' || name === 'GetUnitMaxMana' || name === 'SetUnitMaxMana' || name === 'SetItemName' || name === 'SetItemDescription' || name === 'GetItemDescription' || name === 'SetItemTooltip' || name === 'GetItemTooltip' || name === 'SetItemExtendedTooltip' || name === 'GetItemExtendedTooltip' || name === 'SetItemIconPath' || name === 'GetItemIconPath' || name === 'SetUnitName' || name === 'SetHeroProperName' || name === 'GetUnitBaseDamage' || name === 'SetUnitBaseDamage' || name === 'GetUnitDiceNumber' || name === 'SetUnitDiceNumber' || name === 'GetUnitDiceSides' || name === 'SetUnitDiceSides' || name === 'GetUnitAttackCooldown' || name === 'SetUnitAttackCooldown' || name === 'SetSpcialEffectColorByPlayer' || name === 'SetSpecialEffectColor' || name === 'SetSpecialEffectAlpha' || name === 'SetSpecialEffectScale' || name === 'SetSpecialEffectPosition' || name === 'SetSpecialEffectHeight' || name === 'SetSpecialEffectTimeScale' || name === 'SetSpecialEffectTime' || name === 'SetSpecialEffectOrientation' || name === 'SetSpecialEffectYaw' || name === 'SetSpecialEffectPitch' || name === 'SetSpecialEffectRoll' || name === 'SetSpecialEffectX' || name === 'SetSpecialEffectY' || name === 'SetSpecialEffectZ' || name === 'SetSpecialEffectPositionLoc' || name === 'GetLocalSpecialEffectX' || name === 'GetLocalSpecialEffectY' || name === 'GetLocalSpecialEffectZ' || name === 'GetUnitArmor' || name === 'SetUnitArmor' || name === 'UnitHideAbility' || name === 'UnitDisableAbility' || name === 'UnitCancelTimedLife' || name === 'IsUnitSelectable' || name === 'IsUnitInvulnerable' || name === 'UnitInterruptAttack' || name === 'GetUnitCollisionSize' || name === 'GetAbilityManaCost' || name === 'GetAbilityCooldown' || name === 'SetUnitAbilityCooldown' || name === 'GetUnitAbilityCooldown' || name === 'GetUnitAbilityCooldownRemaining' || name === 'EndUnitAbilityCooldown' || name === 'GetUnitAbilityManaCost' || name === 'SetEventDamage' || // Note, the following two functions are available in the 1.29 PTR, but not in the public version! // They require version 1.30, which at the time of writing is in PTR. name === 'PlaySpecialEffect' || name === 'PlaySpecialEffectWithTimeScale'; } /** * Try to find extended GUI that can be converted back to GUI. * This involves finding specific functions and cases where native function can be emulated via GUI functions. * This can avoid having to convert the extended GUI to custom script. * It's both nicer, and in the case of top-level events and conditions can avoid having to convert whole triggers. * * @param {WeuConverterData} data * @param {ECA|SubParameters} object * @return {boolean} */ function convertInlineGUI(data, object) { let name = object.name; if (name === 'OperatorCompareBoolean') { let value = object.parameters[0].value; if (value === 'IsUnitOwnedByPlayer') { return replaceIsUnitOwned(object); } else if (value === 'IsUnitInRange') { return replaceIsUnitInRange(object); } else if (value === 'IsUnitType' && object.group === -1) { return replaceIsUnitType(object); } else if (value === 'IsUnitRace') { return replaceIsUnitRace(object); } } else if (name === 'SetHeroStr' || name === 'SetHeroAgi' || name === 'SetHeroInt') { return replaceSetHeroStat(object); } else if (name === 'TriggerRegisterUnitStateEvent') { return replaceTriggerRegisterUnitStateEvent(object); } else if (isBlzNeeded(name)) { object.name = `Blz${name}`; // If this is a subparameters object, need to change the name also for the parent parameter. if (object instanceof SubParameters) { data.stack[1].value = `Blz${name}`; } // In the PTR this has 3 parameters, but later it became 2. if (name === 'GetAbilityIcon' || name === 'SetAbilityIcon') { object.parameters.pop(); } return true; } return false; } /** * @param {WeuConverterData} data * @param {ECA|SubParameters} object * @return {boolean} */ function testFunctionCall(data, object) { // Check if this object can be converted back to normal GUI. // If it's already normal GUI, nothing will happen. if (convertInlineGUI(data, object)) { data.output.changes.push({type: 'inlinegui', stack: stackToString(data.stack)}); } // If this function is not from normal GUI, it has to be converted. if (!data.triggerData.isBaseFunction(object.type, object.name)) { return true; } // Check the parameters. // Note that they will also be checked if GUI was inlined. // This is needed, because the inline functions don't check the parameters, only move them around. for (let parameter of object.parameters) { // Check for custom presets. if (parameter.type === 0 && data.triggerData.isCustomPreset(parameter.value)) { return true; } if (testParameter(data, parameter)) { return true; } } return false; } /** * @param {WeuConverterData} data * @param {SubParameters} subParameters * @return {boolean} */ function testSubParameters(data, subParameters) { data.stack.unshift(subParameters); if (testFunctionCall(data, subParameters)) { data.stack.shift(); return true; } data.stack.shift(); return false; } /** * @param {WeuConverterData} data * @param {Parameter} parameter * @return {boolean} */ function testParameter(data, parameter) { data.stack.unshift(parameter); let type = parameter.type; let value = parameter.value; if (type === 1) { if (value.startsWith('gg_')) { // Used to track global generated variables and their status. updateGUIRef(data, value, true); } } else if (type === 2) { if (testSubParameters(data, parameter.subParameters)) { data.stack.shift(); return true; } } data.stack.shift(); return false; } /** * Given the name of the parent of some child ECA, and the child's group, determine if it's a condition. * * @param {string} name * @param {number} group * @return {boolean} */ function isConditionECA(name, group) { if (group !== 0) { return false; } return name === 'AndMultiple' || name === 'OrMultiple' || name === 'IfThenElseMultiple'; } /** * @param {SubParameters} subParameters * @param {number} group * @return {ECA} */ function subParametersToEca(subParameters, group) { let eca = new ECA(); eca.name = subParameters.name; eca.type = subParameters.type; eca.group = group; eca.isEnabled = 1; eca.parameters.push(...subParameters.parameters); return eca; } /** * @param {WeuConverterData} data * @param {ECA} eca * @return {boolean} */ function convertSingleEcaToMultple(data, eca) { if (eca.name === 'IfThenElse') { let parameters = eca.parameters; let ifParam = subParametersToEca(parameters[0].subParameters, 0); let thenParam = subParametersToEca(parameters[1].subParameters, 1); let elseParam = subParametersToEca(parameters[2].subParameters, 2); eca.name = 'IfThenElseMultiple'; eca.parameters.length = 0; eca.ecas.push(ifParam, thenParam, elseParam); return true; } else if (eca.name === 'ForGroup' || eca.name === 'ForForce') { let action = subParametersToEca(eca.parameters[1].subParameters, 0); eca.name = `${eca.name}Multiple`; eca.parameters.length = 1; eca.ecas.push(action); return true; } return false; } /** * @param {WeuConverterData} data * @param {ECA} eca * @return {boolean} */ function testECA(data, eca) { data.stack.unshift(eca); // Test if this function call, or anything down its hierarchy, needs to be converted to custom script. if (testFunctionCall(data, eca)) { // If conversion is needed, try first to see if this is a RoC control flow ECA, and convert it to its TFT equivalent. // This includes things like IfThenElse (RoC) and IfThenElseMultiple (TFT). // This allows to potentially only convert to custom script one part of the control flow block, rather than all of it. if (convertSingleEcaToMultple(data, eca)) { // If the test passes here (that is, false is returned), the TFT conversion allowed to handle the conversion down the hierarchy. // In this case, this ECA no longer needs to be converted to custom script. if (testFunctionCall(data, eca)) { data.stack.shift(); return true; } else { data.output.changes.push({type: 'singletomultiple', stack: stackToString(data.stack)}); } } else { data.stack.shift(); return true; } } let outputEcas = []; // Test the child ECAs if there are any. for (let child of eca.ecas) { if (testECA(data, child)) { let customScripts; // If this is a condition ECA, make a custom script condition. if (isConditionECA(eca.name, child.group)) { let condition = convertFunctionCallToCustomScript(data, child)[0].parameters[0].value; // Normally type 2 (function) ECAs have the call keyword prepended to them. // If one was added, remove it now, since this is a condition. if (condition.startsWith('call ')) { condition = condition.slice(5); } let finalCondition; let returnValue; // IfThenElseMultiple and AndMultiple return false if any condition is false. // OrMultiple needs to return true if any condition is true. if (eca.name === 'OrMultiple') { finalCondition = condition; returnValue = 'true'; } else { finalCondition = `not (${condition})`; returnValue = 'false'; } customScripts = [ createCustomScriptECA(`if ${finalCondition} then`), createCustomScriptECA(`return ${returnValue}`), createCustomScriptECA('endif'), ]; } else { customScripts = convertFunctionCallToCustomScript(data, child); } // All of the custom scripts should be in the same group as the original child. for (let script of customScripts) { script.group = child.group; } data.output.changes.push({type: 'inlinecustomscript', stack: stackToString(data.stack), data: customScripts.map((eca) => eca.parameters[0].value).join('\n')}); outputEcas.push(...customScripts); } else { outputEcas.push(child); } } eca.ecas = outputEcas; data.stack.shift(); return false; } /** * @param {WeuConverterData} data * @param {Trigger} trigger * @return {boolean} */ function testTrigger(data, trigger) { data.stack.unshift(trigger); let eventsAndConditions = []; let actions = []; for (let eca of trigger.ecas) { let type = eca.type; if (type === 0 || type === 1) { eventsAndConditions.push(eca); } else if (type === 2) { actions.push(eca); } } let outputEcas = []; for (let eventOrCondition of eventsAndConditions) { if (testECA(data, eventOrCondition)) { data.stack.shift(); return true; } outputEcas.push(eventOrCondition); } for (let action of actions) { if (testECA(data, action)) { let customScripts = convertFunctionCallToCustomScript(data, action); data.output.changes.push({type: 'inlinecustomscript', stack: stackToString(data.stack), data: customScripts.map((eca) => eca.parameters[0].value).join('\n')}); outputEcas.push(...customScripts); } else { outputEcas.push(action); } } trigger.ecas = outputEcas; data.stack.shift(); return false; } /** * @param {wtg.File} triggerFile * @param {wct.File} customTextTriggerFile * @param {WeuConverterData} data */ function saveGUIRefs(triggerFile, customTextTriggerFile, data) { let references = []; // Get all of the references that are no longer references. for (let entry of Object.entries(data.preplacedObjects)) { if (!entry[1]) { references.push(entry[0]); } } // If there are indeed missing references, add them to a new trigger. if (references.length) { let trigger = new Trigger(); trigger.name = 'PreplacedObjectReferences'; trigger.isEnabled = 1; trigger.isInitiallyOff = 1; for (let reference of references) { let eca = new ECA(); eca.type = 2; eca.isEnabled = 1; if (reference.startsWith('gg_unit')) { eca.name = 'RemoveUnit'; } else if (reference.startsWith('gg_dest')) { eca.name = 'RemoveDestructable'; } let parameter = new Parameter(); parameter.type = 1; parameter.value = reference; eca.parameters[0] = parameter; trigger.ecas.push(eca); } triggerFile.triggers.push(trigger); customTextTriggerFile.triggers.push(new CustomTextTrigger()); data.output.changes.push({type: 'references', data: references}); } } /** * Convert extended GUI of a map back to something the World Editor can open. * The conversion depends on the given TriggerData object, which must be filled by the caller. * The conversion is in-place. * * @param {War3Map} map * @param {TriggerData} triggerData * @param {TriggerData} weTriggerData * @return {Object} */ export default function convertWeu(map, triggerData, weTriggerData) { let triggerFile; let customTextTriggerFile; let data = new WeuConverterData(triggerData); let output = data.output; // Try to read the triggers file using the custom trigger data. try { triggerFile = map.readTriggers(triggerData); } catch (e) { output.error = `Failed to read the triggers file: ${e}`; return output; } // Try to read the custom text triggers file. try { customTextTriggerFile = map.readCustomTextTriggers(); } catch (e) { output.error = `Failed to read the custom text triggers file: ${e}`; return output; } try { data.stringTable = map.readStringTable(); } catch (e) { output.error = `Failed to read the string table file: ${e}`; return output; } // Test and convert the triggers as needed. for (let trigger of triggerFile.triggers) { try { if (testTrigger(data, trigger)) { // For now don't bother with converting whole triggers, since GUI conversions manage all of the maps I saw so far. output.error = `Trigger ${trigger.name} needs to be converted due to top-level event/condition, but full trigger conversion is not implemented yet`; return output; } } catch (e) { output.error = `Error at trigger ${trigger.name}: ${e}`; return output; } } // WE will only generate global variables for preplaced objects that are referenced directly by GUI. // Referencing them in custom text ECAs or custom text triggers doesn't cut it. // This function saves such references if they are deemed to be lost due to the conversion. // It does this by adding a new trigger called PreplacedObjectReferences, which is not initially on. // In it an ECA is added for each reference. // Note that this is not the case for all preplaced objects. // For example, triggers and regions seem to always be available. // For now only units and destructibles are checked. saveGUIRefs(triggerFile, customTextTriggerFile, data); // If there are generated functions, add them to the custom text triggers file. if (data.generatedFunctions.length) { for (let generatedFunction of data.generatedFunctions) { customTextTriggerFile.trigger.text += `\r\n${generatedFunction.replace(/\n/g, '\r\n')}`; } } // Save the triggers file back. map.set('war3map.wtg', triggerFile.save()); // Save the custom text triggers file back. map.set('war3map.wct', customTextTriggerFile.save()); // Now try to re-read the triggers file, but using the normal WE trigger data. // If this fails, WE will fail too. try { map.readTriggers(weTriggerData); } catch (e) { output.error = `Failed to validate the triggers file: ${e}`; return output; } output.ok = true; return output; }