Index: ps/trunk/binaries/data/mods/public/simulation/components/Damage.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/Damage.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/Damage.js (nonexistent) @@ -1,312 +0,0 @@ -function Damage() {} - -Damage.prototype.Schema = - ""; - -Damage.prototype.Init = function() -{ -}; - -/** - * Gives the position of the given entity, taking the lateness into account. - * @param {number} ent - Entity id of the entity we are finding the location for. - * @param {number} lateness - The time passed since the expected time to fire the function. - * @return {Vector3D} The location of the entity. - */ -Damage.prototype.InterpolatedLocation = function(ent, lateness) -{ - let cmpTargetPosition = Engine.QueryInterface(ent, IID_Position); - if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) // TODO: handle dead target properly - return undefined; - let curPos = cmpTargetPosition.GetPosition(); - let prevPos = cmpTargetPosition.GetPreviousPosition(); - let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); - let turnLength = cmpTimer.GetLatestTurnLength(); - return new Vector3D( - (curPos.x * (turnLength - lateness) + prevPos.x * lateness) / turnLength, - 0, - (curPos.z * (turnLength - lateness) + prevPos.z * lateness) / turnLength); -}; - -/** - * Test if a point is inside of an entity's footprint. - * @param {number} ent - Id of the entity we are checking with. - * @param {Vector3D} point - The point we are checking with. - * @param {number} lateness - The time passed since the expected time to fire the function. - * @return {boolean} True if the point is inside of the entity's footprint. - */ -Damage.prototype.TestCollision = function(ent, point, lateness) -{ - let targetPosition = this.InterpolatedLocation(ent, lateness); - if (!targetPosition) - return false; - - let cmpFootprint = Engine.QueryInterface(ent, IID_Footprint); - if (!cmpFootprint) - return false; - - let targetShape = cmpFootprint.GetShape(); - - if (!targetShape) - return false; - - if (targetShape.type == "circle") - return targetPosition.horizDistanceToSquared(point) < targetShape.radius * targetShape.radius; - - if (targetShape.type == "square") - { - let angle = Engine.QueryInterface(ent, IID_Position).GetRotation().y; - let distance = Vector2D.from3D(Vector3D.sub(point, targetPosition)).rotate(-angle); - return Math.abs(distance.x) < targetShape.width / 2 && Math.abs(distance.y) < targetShape.depth / 2; - } - - warn("TestCollision called with an invalid footprint shape"); - return false; -}; - -/** - * Get the list of players affected by the damage. - * @param {number} attackerOwner - The player id of the attacker. - * @param {boolean} friendlyFire - A flag indicating if allied entities are also damaged. - * @return {number[]} The ids of players need to be damaged. - */ -Damage.prototype.GetPlayersToDamage = function(attackerOwner, friendlyFire) -{ - if (!friendlyFire) - return QueryPlayerIDInterface(attackerOwner).GetEnemies(); - - return Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetAllPlayers(); -}; - -/** - * Handles hit logic after the projectile travel time has passed. - * @param {Object} data - The data sent by the caller. - * @param {number} data.attacker - The entity id of the attacker. - * @param {number} data.target - The entity id of the target. - * @param {Vector2D} data.origin - The origin of the projectile hit. - * @param {Object} data.strengths - Data of the form { 'hack': number, 'pierce': number, 'crush': number }. - * @param {string} data.type - The type of damage. - * @param {number} data.attackerOwner - The player id of the owner of the attacker. - * @param {boolean} data.isSplash - A flag indicating if it's splash damage. - * @param {Vector3D} data.position - The expected position of the target. - * @param {number} data.projectileId - The id of the projectile. - * @param {Vector3D} data.direction - The unit vector defining the direction. - * @param {Object} data.bonus - The attack bonus template from the attacker. - * @param {string} data.attackImpactSound - The name of the sound emited on impact. - * @param {Object} data.statusEffects - Status effects eg. poisoning, burning etc. - * ***When splash damage*** - * @param {boolean} data.friendlyFire - A flag indicating if allied entities are also damaged. - * @param {number} data.radius - The radius of the splash damage. - * @param {string} data.shape - The shape of the splash range. - * @param {Object} data.splashBonus - The attack bonus template from the attacker. - * @param {Object} data.splashStrengths - Data of the form { 'hack': number, 'pierce': number, 'crush': number }. - */ -Damage.prototype.MissileHit = function(data, lateness) -{ - if (!data.position) - return; - - let cmpSoundManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_SoundManager); - if (cmpSoundManager && data.attackImpactSound) - cmpSoundManager.PlaySoundGroupAtPosition(data.attackImpactSound, data.position); - - // Do this first in case the direct hit kills the target - if (data.isSplash) - { - this.CauseDamageOverArea({ - "attacker": data.attacker, - "origin": Vector2D.from3D(data.position), - "radius": data.radius, - "shape": data.shape, - "strengths": data.splashStrengths, - "splashBonus": data.splashBonus, - "direction": data.direction, - "playersToDamage": this.GetPlayersToDamage(data.attackerOwner, data.friendlyFire), - "type": data.type, - "attackerOwner": data.attackerOwner - }); - } - - let cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager); - - // Deal direct damage if we hit the main target - // and if the target has DamageReceiver (not the case for a mirage for example) - let cmpDamageReceiver = Engine.QueryInterface(data.target, IID_DamageReceiver); - if (cmpDamageReceiver && this.TestCollision(data.target, data.position, lateness)) - { - data.multiplier = GetDamageBonus(data.attacker, data.target, data.type, data.bonus); - this.CauseDamage(data); - cmpProjectileManager.RemoveProjectile(data.projectileId); - - let cmpStatusReceiver = Engine.QueryInterface(data.target, IID_StatusEffectsReceiver); - if (cmpStatusReceiver && data.statusEffects) - cmpStatusReceiver.InflictEffects(data.statusEffects); - - return; - } - - let targetPosition = this.InterpolatedLocation(data.target, lateness); - if (!targetPosition) - return; - - // If we didn't hit the main target look for nearby units - let cmpPlayer = QueryPlayerIDInterface(data.attackerOwner); - let ents = this.EntitiesNearPoint(Vector2D.from3D(data.position), targetPosition.horizDistanceTo(data.position) * 2, cmpPlayer.GetEnemies()); - for (let ent of ents) - { - if (!this.TestCollision(ent, data.position, lateness)) - continue; - - this.CauseDamage({ - "strengths": data.strengths, - "target": ent, - "attacker": data.attacker, - "multiplier": GetDamageBonus(data.attacker, ent, data.type, data.bonus), - "type": data.type, - "attackerOwner": data.attackerOwner - }); - cmpProjectileManager.RemoveProjectile(data.projectileId); - break; - } -}; - -/** - * Damages units around a given origin. - * @param {Object} data - The data sent by the caller. - * @param {number} data.attacker - The entity id of the attacker. - * @param {Vector2D} data.origin - The origin of the projectile hit. - * @param {number} data.radius - The radius of the splash damage. - * @param {string} data.shape - The shape of the radius. - * @param {Object} data.strengths - Data of the form { 'hack': number, 'pierce': number, 'crush': number }. - * @param {string} data.type - The type of damage. - * @param {number} data.attackerOwner - The player id of the attacker. - * @param {Vector3D} [data.direction] - The unit vector defining the direction. Needed for linear splash damage. - * @param {Object} data.splashBonus - The attack bonus template from the attacker. - * @param {number[]} data.playersToDamage - The array of player id's to damage. - */ -Damage.prototype.CauseDamageOverArea = function(data) -{ - // Get nearby entities and define variables - let nearEnts = this.EntitiesNearPoint(data.origin, data.radius, data.playersToDamage); - let damageMultiplier = 1; - - // Cycle through all the nearby entities and damage it appropriately based on its distance from the origin. - for (let ent of nearEnts) - { - let entityPosition = Engine.QueryInterface(ent, IID_Position).GetPosition2D(); - if (data.shape == 'Circular') // circular effect with quadratic falloff in every direction - damageMultiplier = 1 - data.origin.distanceToSquared(entityPosition) / (data.radius * data.radius); - else if (data.shape == 'Linear') // linear effect with quadratic falloff in two directions (only used for certain missiles) - { - // Get position of entity relative to splash origin. - let relativePos = entityPosition.sub(data.origin); - - // Get the position relative to the missile direction. - let direction = Vector2D.from3D(data.direction); - let parallelPos = relativePos.dot(direction); - let perpPos = relativePos.cross(direction); - - // The width of linear splash is one fifth of the normal splash radius. - let width = data.radius / 5; - - // Check that the unit is within the distance splash width of the line starting at the missile's - // landing point which extends in the direction of the missile for length splash radius. - if (parallelPos >= 0 && Math.abs(perpPos) < width) // If in radius, quadratic falloff in both directions - damageMultiplier = (1 - parallelPos * parallelPos / (data.radius * data.radius)) * - (1 - perpPos * perpPos / (width * width)); - else - damageMultiplier = 0; - } - else // In case someone calls this function with an invalid shape. - { - warn("The " + data.shape + " splash damage shape is not implemented!"); - } - - if (data.splashBonus) - damageMultiplier *= GetDamageBonus(data.attacker, ent, data.type, data.splashBonus); - - // Call CauseDamage which reduces the hitpoints, posts network command, plays sounds.... - this.CauseDamage({ - "strengths": data.strengths, - "target": ent, - "attacker": data.attacker, - "multiplier": damageMultiplier, - "type": data.type + ".Splash", - "attackerOwner": data.attackerOwner - }); - } -}; - -/** - * Causes damage on a given unit. - * @param {Object} data - The data passed by the caller. - * @param {Object} data.strengths - Data in the form of { 'hack': number, 'pierce': number, 'crush': number }. - * @param {number} data.target - The entity id of the target. - * @param {number} data.attacker - The entity id of the attacker. - * @param {number} data.multiplier - The damage multiplier. - * @param {string} data.type - The type of damage. - * @param {number} data.attackerOwner - The player id of the attacker. - */ -Damage.prototype.CauseDamage = function(data) -{ - let cmpDamageReceiver = Engine.QueryInterface(data.target, IID_DamageReceiver); - if (!cmpDamageReceiver) - return; - - let targetState = cmpDamageReceiver.TakeDamage(data.strengths, data.multiplier); - - let cmpPromotion = Engine.QueryInterface(data.attacker, IID_Promotion); - let cmpLoot = Engine.QueryInterface(data.target, IID_Loot); - let cmpHealth = Engine.QueryInterface(data.target, IID_Health); - if (cmpPromotion && cmpLoot && cmpLoot.GetXp() > 0) - cmpPromotion.IncreaseXp(cmpLoot.GetXp() * -targetState.change / cmpHealth.GetMaxHitpoints()); - - if (targetState.killed) - this.TargetKilled(data.attacker, data.target, data.attackerOwner); - - Engine.PostMessage(data.target, MT_Attacked, { "attacker": data.attacker, "target": data.target, "type": data.type, "damage": -targetState.change, "attackerOwner": data.attackerOwner }); -}; - -/** - * Gets entities near a give point for given players. - * @param {Vector2D} origin - The point to check around. - * @param {number} radius - The radius around the point to check. - * @param {number[]} players - The players of which we need to check entities. - * @return {number[]} The id's of the entities in range of the given point. - */ -Damage.prototype.EntitiesNearPoint = function(origin, radius, players) -{ - // If there is insufficient data return an empty array. - if (!origin || !radius || !players) - return []; - - return Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).ExecuteQueryAroundPos(origin, 0, radius, players, IID_DamageReceiver); -}; - -/** - * Called when a unit kills something (another unit, building, animal etc). - * @param {number} attacker - The entity id of the killer. - * @param {number} target - The entity id of the target. - * @param {number} attackerOwner - The player id of the attacker. - */ -Damage.prototype.TargetKilled = function(attacker, target, attackerOwner) -{ - let cmpAttackerOwnership = Engine.QueryInterface(attacker, IID_Ownership); - let atkOwner = cmpAttackerOwnership && cmpAttackerOwnership.GetOwner() != INVALID_PLAYER ? cmpAttackerOwnership.GetOwner() : attackerOwner; - - // Add to killer statistics. - let cmpKillerPlayerStatisticsTracker = QueryPlayerIDInterface(atkOwner, IID_StatisticsTracker); - if (cmpKillerPlayerStatisticsTracker) - cmpKillerPlayerStatisticsTracker.KilledEntity(target); - // Add to loser statistics. - let cmpTargetPlayerStatisticsTracker = QueryOwnerInterface(target, IID_StatisticsTracker); - if (cmpTargetPlayerStatisticsTracker) - cmpTargetPlayerStatisticsTracker.LostEntity(target); - - // If killer can collect loot, let's try to collect it. - let cmpLooter = Engine.QueryInterface(attacker, IID_Looter); - if (cmpLooter) - cmpLooter.Collect(target); -}; - -Engine.RegisterSystemComponentType(IID_Damage, "Damage", Damage); Property changes on: ps/trunk/binaries/data/mods/public/simulation/components/Damage.js ___________________________________________________________________ Deleted: svn:eol-style ## -1 +0,0 ## -native \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/simulation/components/interfaces/Damage.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/interfaces/Damage.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/interfaces/Damage.js (nonexistent) @@ -1 +0,0 @@ -Engine.RegisterInterface("Damage"); Property changes on: ps/trunk/binaries/data/mods/public/simulation/components/interfaces/Damage.js ___________________________________________________________________ Deleted: svn:eol-style ## -1 +0,0 ## -native \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/simulation/components/interfaces/DamageReceiver.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/interfaces/DamageReceiver.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/interfaces/DamageReceiver.js (nonexistent) @@ -1,6 +0,0 @@ -Engine.RegisterInterface("DamageReceiver"); - -/** - * Message of the form { "entity": entity, "invulnerability": true/false } - */ -Engine.RegisterMessageType("InvulnerabilityChanged"); Property changes on: ps/trunk/binaries/data/mods/public/simulation/components/interfaces/DamageReceiver.js ___________________________________________________________________ Deleted: svn:eol-style ## -1 +0,0 ## -native \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/simulation/helpers/DamageTypes.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/helpers/DamageTypes.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/helpers/DamageTypes.js (nonexistent) @@ -1,22 +0,0 @@ -/** - * Builds a RelaxRNG schema based on currently valid elements. - * - * To prevent validation errors, disabled damage types are included in the schema. - * - * @param {string} helptext - Text displayed as help - * @return {string} - RelaxNG schema string - */ -function BuildDamageTypesSchema(helptext = "") -{ - return "" + - "" + - "" + - // Armour requires Foundation to not be a damage type. - "Foundation" + - "" + - "" + - "" + - ""; -} - -Engine.RegisterGlobal("BuildDamageTypesSchema", BuildDamageTypesSchema); Property changes on: ps/trunk/binaries/data/mods/public/simulation/helpers/DamageTypes.js ___________________________________________________________________ Deleted: svn:eol-style ## -1 +0,0 ## -native \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/globalscripts/AttackEffects.js =================================================================== --- ps/trunk/binaries/data/mods/public/globalscripts/AttackEffects.js (nonexistent) +++ ps/trunk/binaries/data/mods/public/globalscripts/AttackEffects.js (revision 22754) @@ -0,0 +1,16 @@ +// TODO: could be worth putting this in json files someday +const g_EffectTypes = ["Damage", "Capture", "GiveStatus"]; +const g_EffectReceiver = { + "Damage": { + "IID": "IID_Health", + "method": "TakeDamage" + }, + "Capture": { + "IID": "IID_Capturable", + "method": "Capture" + }, + "GiveStatus": { + "IID": "IID_StatusEffectsReceiver", + "method": "GiveStatus" + } +}; Property changes on: ps/trunk/binaries/data/mods/public/globalscripts/AttackEffects.js ___________________________________________________________________ Added: svn:executable ## -0,0 +1 ## +* \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/globalscripts/Templates.js =================================================================== --- ps/trunk/binaries/data/mods/public/globalscripts/Templates.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/globalscripts/Templates.js (revision 22754) @@ -1,545 +1,551 @@ /** * Loads history and gameplay data of all civs. * * @param selectableOnly {boolean} - Only load civs that can be selected * in the gamesetup. Scenario maps might set non-selectable civs. */ function loadCivFiles(selectableOnly) { let propertyNames = [ "Code", "Culture", "Name", "Emblem", "History", "Music", "Factions", "CivBonuses", "TeamBonuses", "Structures", "StartEntities", "Formations", "AINames", "SkirmishReplacements", "SelectableInGameSetup"]; let civData = {}; for (let filename of Engine.ListDirectoryFiles("simulation/data/civs/", "*.json", false)) { let data = Engine.ReadJSONFile(filename); for (let prop of propertyNames) if (data[prop] === undefined) throw new Error(filename + " doesn't contain " + prop); if (!selectableOnly || data.SelectableInGameSetup) civData[data.Code] = data; } return civData; } /** * Gets an array of all classes for this identity template */ function GetIdentityClasses(template) { var classList = []; if (template.Classes && template.Classes._string) classList = classList.concat(template.Classes._string.split(/\s+/)); if (template.VisibleClasses && template.VisibleClasses._string) classList = classList.concat(template.VisibleClasses._string.split(/\s+/)); if (template.Rank) classList = classList.concat(template.Rank); return classList; } /** * Gets an array with all classes for this identity template * that should be shown in the GUI */ function GetVisibleIdentityClasses(template) { if (template.VisibleClasses && template.VisibleClasses._string) return template.VisibleClasses._string.split(/\s+/); return []; } /** * Check if a given list of classes matches another list of classes. * Useful f.e. for checking identity classes. * * @param classes - List of the classes to check against. * @param match - Either a string in the form * "Class1 Class2+Class3" * where spaces are handled as OR and '+'-signs as AND, * and ! is handled as NOT, thus Class1+!Class2 = Class1 AND NOT Class2. * Or a list in the form * [["Class1"], ["Class2", "Class3"]] * where the outer list is combined as OR, and the inner lists are AND-ed. * Or a hybrid format containing a list of strings, where the list is * combined as OR, and the strings are split by space and '+' and AND-ed. * * @return undefined if there are no classes or no match object * true if the the logical combination in the match object matches the classes * false otherwise. */ function MatchesClassList(classes, match) { if (!match || !classes) return undefined; // Transform the string to an array if (typeof match == "string") match = match.split(/\s+/); for (let sublist of match) { // If the elements are still strings, split them by space or by '+' if (typeof sublist == "string") sublist = sublist.split(/[+\s]+/); if (sublist.every(c => (c[0] == "!" && classes.indexOf(c.substr(1)) == -1) || (c[0] != "!" && classes.indexOf(c) != -1))) return true; } return false; } /** * Gets the value originating at the value_path as-is, with no modifiers applied. * * @param {object} template - A valid template as returned from a template loader. * @param {string} value_path - Route to value within the xml template structure. * @return {number} */ function GetBaseTemplateDataValue(template, value_path) { let current_value = template; for (let property of value_path.split("/")) current_value = current_value[property] || 0; return +current_value; } /** * Gets the value originating at the value_path with the modifiers dictated by the mod_key applied. * * @param {object} template - A valid template as returned from a template loader. * @param {string} value_path - Route to value within the xml template structure. * @param {string} mod_key - Tech modification key, if different from value_path. * @param {number} player - Optional player id. * @param {object} modifiers - Value modifiers from auto-researched techs, unit upgrades, * etc. Optional as only used if no player id provided. * @return {number} Modifier altered value. */ function GetModifiedTemplateDataValue(template, value_path, mod_key, player, modifiers={}) { let current_value = GetBaseTemplateDataValue(template, value_path); mod_key = mod_key || value_path; if (player) current_value = ApplyValueModificationsToTemplate(mod_key, current_value, player, template); else if (modifiers) current_value = GetTechModifiedProperty(modifiers, GetIdentityClasses(template.Identity), mod_key, current_value); // Using .toFixed() to get around spidermonkey's treatment of numbers (3 * 1.1 = 3.3000000000000003 for instance). return +current_value.toFixed(8); } /** * Get information about a template with or without technology modifications. * * NOTICE: The data returned here should have the same structure as * the object returned by GetEntityState and GetExtendedEntityState! * * @param {object} template - A valid template as returned by the template loader. * @param {number} player - An optional player id to get the technology modifications * of properties. * @param {object} auraTemplates - In the form of { key: { "auraName": "", "auraDescription": "" } }. * @param {object} resources - An instance of the Resources prototype. * @param {object} damageTypes - An instance of the DamageTypes prototype. * @param {object} modifiers - Modifications from auto-researched techs, unit upgrades * etc. Optional as only used if there's no player * id provided. */ function GetTemplateDataHelper(template, player, auraTemplates, resources, damageTypes, modifiers={}) { // Return data either from template (in tech tree) or sim state (ingame). // @param {string} value_path - Route to the value within the template. // @param {string} mod_key - Modification key, if not the same as the value_path. let getEntityValue = function(value_path, mod_key) { return GetModifiedTemplateDataValue(template, value_path, mod_key, player, modifiers); }; let ret = {}; if (template.Armour) { ret.armour = {}; for (let damageType in template.Armour) if (damageType != "Foundation") ret.armour[damageType] = getEntityValue("Armour/" + damageType); } + let getAttackEffects = (temp, path) => { + let effects = {}; + if (temp.Capture) + effects.Capture = getEntityValue(path + "/Capture"); + + if (temp.Damage) + { + effects.Damage = {}; + for (let damageType in temp.Damage) + effects.Damage[damageType] = getEntityValue(path + "/Damage/" + damageType); + } + + // TODO: status effects + return effects; + }; + if (template.Attack) { ret.attack = {}; for (let type in template.Attack) { let getAttackStat = function(stat) { return getEntityValue("Attack/" + type + "/" + stat); }; - if (type == "Capture") - ret.attack.Capture = { - "value": getAttackStat("Value") - }; - else - { - ret.attack[type] = { - "minRange": getAttackStat("MinRange"), - "maxRange": getAttackStat("MaxRange"), - "elevationBonus": getAttackStat("ElevationBonus"), - "damage": {} - }; - for (let damageType in template.Attack[type].Damage) - ret.attack[type].damage[damageType] = getAttackStat("Damage/" + damageType); + ret.attack[type] = { + "minRange": getAttackStat("MinRange"), + "maxRange": getAttackStat("MaxRange"), + "elevationBonus": getAttackStat("ElevationBonus"), + }; + + ret.attack[type].elevationAdaptedRange = Math.sqrt(ret.attack[type].maxRange * + (2 * ret.attack[type].elevationBonus + ret.attack[type].maxRange)); - ret.attack[type].elevationAdaptedRange = Math.sqrt(ret.attack[type].maxRange * - (2 * ret.attack[type].elevationBonus + ret.attack[type].maxRange)); - } ret.attack[type].repeatTime = getAttackStat("RepeatTime"); + Object.assign(ret.attack[type], getAttackEffects(template.Attack[type], "Attack/" + type)); + if (template.Attack[type].Splash) { ret.attack[type].splash = { // true if undefined "friendlyFire": template.Attack[type].Splash.FriendlyFire != "false", "shape": template.Attack[type].Splash.Shape, - "damage": {} }; - for (let damageType in template.Attack[type].Splash.Damage) - ret.attack[type].splash.damage[damageType] = getAttackStat("Splash/Damage/" + damageType); + Object.assign(ret.attack[type].splash, getAttackEffects(template.Attack[type].Splash, "Attack/" + type + "/Splash")); } } } if (template.DeathDamage) { ret.deathDamage = { "friendlyFire": template.DeathDamage.FriendlyFire != "false", - "damage": {} }; - for (let damageType in template.DeathDamage.Damage) - ret.deathDamage.damage[damageType] = getEntityValue("DeathDamage/Damage/" + damageType); + + Object.assign(ret.deathDamage, getAttackEffects(template.DeathDamage, "DeathDamage")); } if (template.Auras && auraTemplates) { ret.auras = {}; for (let auraID of template.Auras._string.split(/\s+/)) { let aura = auraTemplates[auraID]; ret.auras[auraID] = { "name": aura.auraName, "description": aura.auraDescription || null, "radius": aura.radius || null }; } } if (template.BuildingAI) ret.buildingAI = { "defaultArrowCount": Math.round(getEntityValue("BuildingAI/DefaultArrowCount")), "garrisonArrowMultiplier": getEntityValue("BuildingAI/GarrisonArrowMultiplier"), "maxArrowCount": Math.round(getEntityValue("BuildingAI/MaxArrowCount")) }; if (template.BuildRestrictions) { // required properties ret.buildRestrictions = { "placementType": template.BuildRestrictions.PlacementType, "territory": template.BuildRestrictions.Territory, "category": template.BuildRestrictions.Category, }; // optional properties if (template.BuildRestrictions.Distance) { ret.buildRestrictions.distance = { "fromClass": template.BuildRestrictions.Distance.FromClass, }; if (template.BuildRestrictions.Distance.MinDistance) ret.buildRestrictions.distance.min = getEntityValue("BuildRestrictions/Distance/MinDistance"); if (template.BuildRestrictions.Distance.MaxDistance) ret.buildRestrictions.distance.max = getEntityValue("BuildRestrictions/Distance/MaxDistance"); } } if (template.TrainingRestrictions) ret.trainingRestrictions = { "category": template.TrainingRestrictions.Category, }; if (template.Cost) { ret.cost = {}; for (let resCode in template.Cost.Resources) ret.cost[resCode] = getEntityValue("Cost/Resources/" + resCode); if (template.Cost.Population) ret.cost.population = getEntityValue("Cost/Population"); if (template.Cost.PopulationBonus) ret.cost.populationBonus = getEntityValue("Cost/PopulationBonus"); if (template.Cost.BuildTime) ret.cost.time = getEntityValue("Cost/BuildTime"); } if (template.Footprint) { ret.footprint = { "height": template.Footprint.Height }; if (template.Footprint.Square) ret.footprint.square = { "width": +template.Footprint.Square["@width"], "depth": +template.Footprint.Square["@depth"] }; else if (template.Footprint.Circle) ret.footprint.circle = { "radius": +template.Footprint.Circle["@radius"] }; else warn("GetTemplateDataHelper(): Unrecognized Footprint type"); } if (template.GarrisonHolder) { ret.garrisonHolder = { "buffHeal": getEntityValue("GarrisonHolder/BuffHeal") }; if (template.GarrisonHolder.Max) ret.garrisonHolder.capacity = getEntityValue("GarrisonHolder/Max"); } if (template.Heal) ret.heal = { "hp": getEntityValue("Heal/HP"), "range": getEntityValue("Heal/Range"), "rate": getEntityValue("Heal/Rate") }; if (template.ResourceGatherer) { ret.resourceGatherRates = {}; let baseSpeed = getEntityValue("ResourceGatherer/BaseSpeed"); for (let type in template.ResourceGatherer.Rates) ret.resourceGatherRates[type] = getEntityValue("ResourceGatherer/Rates/"+ type) * baseSpeed; } if (template.ResourceTrickle) { ret.resourceTrickle = { "interval": +template.ResourceTrickle.Interval, "rates": {} }; for (let type in template.ResourceTrickle.Rates) ret.resourceTrickle.rates[type] = getEntityValue("ResourceTrickle/Rates/" + type); } if (template.Loot) { ret.loot = {}; for (let type in template.Loot) ret.loot[type] = getEntityValue("Loot/"+ type); } if (template.Obstruction) { ret.obstruction = { "active": ("" + template.Obstruction.Active == "true"), "blockMovement": ("" + template.Obstruction.BlockMovement == "true"), "blockPathfinding": ("" + template.Obstruction.BlockPathfinding == "true"), "blockFoundation": ("" + template.Obstruction.BlockFoundation == "true"), "blockConstruction": ("" + template.Obstruction.BlockConstruction == "true"), "disableBlockMovement": ("" + template.Obstruction.DisableBlockMovement == "true"), "disableBlockPathfinding": ("" + template.Obstruction.DisableBlockPathfinding == "true"), "shape": {} }; if (template.Obstruction.Static) { ret.obstruction.shape.type = "static"; ret.obstruction.shape.width = +template.Obstruction.Static["@width"]; ret.obstruction.shape.depth = +template.Obstruction.Static["@depth"]; } else if (template.Obstruction.Unit) { ret.obstruction.shape.type = "unit"; ret.obstruction.shape.radius = +template.Obstruction.Unit["@radius"]; } else ret.obstruction.shape.type = "cluster"; } if (template.Pack) ret.pack = { "state": template.Pack.State, "time": getEntityValue("Pack/Time"), }; if (template.Health) ret.health = Math.round(getEntityValue("Health/Max")); if (template.Identity) { ret.selectionGroupName = template.Identity.SelectionGroupName; ret.name = { "specific": (template.Identity.SpecificName || template.Identity.GenericName), "generic": template.Identity.GenericName }; ret.icon = template.Identity.Icon; ret.tooltip = template.Identity.Tooltip; ret.requiredTechnology = template.Identity.RequiredTechnology; ret.visibleIdentityClasses = GetVisibleIdentityClasses(template.Identity); ret.nativeCiv = template.Identity.Civ; } if (template.UnitMotion) { ret.speed = { "walk": getEntityValue("UnitMotion/WalkSpeed"), }; ret.speed.run = getEntityValue("UnitMotion/WalkSpeed"); if (template.UnitMotion.RunMultiplier) ret.speed.run *= getEntityValue("UnitMotion/RunMultiplier"); } if (template.Upgrade) { ret.upgrades = []; for (let upgradeName in template.Upgrade) { let upgrade = template.Upgrade[upgradeName]; let cost = {}; if (upgrade.Cost) for (let res in upgrade.Cost) cost[res] = getEntityValue("Upgrade/" + upgradeName + "/Cost/" + res, "Upgrade/Cost/" + res); if (upgrade.Time) cost.time = getEntityValue("Upgrade/" + upgradeName + "/Time", "Upgrade/Time"); ret.upgrades.push({ "entity": upgrade.Entity, "tooltip": upgrade.Tooltip, "cost": cost, "icon": upgrade.Icon || undefined, "requiredTechnology": upgrade.RequiredTechnology || undefined }); } } if (template.ProductionQueue) { ret.techCostMultiplier = {}; for (let res in template.ProductionQueue.TechCostMultiplier) ret.techCostMultiplier[res] = getEntityValue("ProductionQueue/TechCostMultiplier/" + res); } if (template.Trader) ret.trader = { "GainMultiplier": getEntityValue("Trader/GainMultiplier") }; if (template.WallSet) { ret.wallSet = { "templates": { "tower": template.WallSet.Templates.Tower, "gate": template.WallSet.Templates.Gate, "fort": template.WallSet.Templates.Fort || "structures/" + template.Identity.Civ + "_fortress", "long": template.WallSet.Templates.WallLong, "medium": template.WallSet.Templates.WallMedium, "short": template.WallSet.Templates.WallShort }, "maxTowerOverlap": +template.WallSet.MaxTowerOverlap, "minTowerOverlap": +template.WallSet.MinTowerOverlap }; if (template.WallSet.Templates.WallEnd) ret.wallSet.templates.end = template.WallSet.Templates.WallEnd; if (template.WallSet.Templates.WallCurves) ret.wallSet.templates.curves = template.WallSet.Templates.WallCurves.split(" "); } if (template.WallPiece) ret.wallPiece = { "length": +template.WallPiece.Length, "angle": +(template.WallPiece.Orientation || 1) * Math.PI, "indent": +(template.WallPiece.Indent || 0), "bend": +(template.WallPiece.Bend || 0) * Math.PI }; return ret; } /** * Get basic information about a technology template. * @param {object} template - A valid template as obtained by loading the tech JSON file. * @param {string} civ - Civilization for which the tech requirements should be calculated. */ function GetTechnologyBasicDataHelper(template, civ) { return { "name": { "generic": template.genericName }, "icon": template.icon ? "technologies/" + template.icon : undefined, "description": template.description, "reqs": DeriveTechnologyRequirements(template, civ), "modifications": template.modifications, "affects": template.affects, "replaces": template.replaces }; } /** * Get information about a technology template. * @param {object} template - A valid template as obtained by loading the tech JSON file. * @param {string} civ - Civilization for which the specific name and tech requirements should be returned. */ function GetTechnologyDataHelper(template, civ, resources) { let ret = GetTechnologyBasicDataHelper(template, civ); if (template.specificName) ret.name.specific = template.specificName[civ] || template.specificName.generic; ret.cost = { "time": template.researchTime ? +template.researchTime : 0 }; for (let type of resources.GetCodes()) ret.cost[type] = +(template.cost && template.cost[type] || 0); ret.tooltip = template.tooltip; ret.requirementsTooltip = template.requirementsTooltip || ""; return ret; } function calculateCarriedResources(carriedResources, tradingGoods) { var resources = {}; if (carriedResources) for (let resource of carriedResources) resources[resource.type] = (resources[resource.type] || 0) + resource.amount; if (tradingGoods && tradingGoods.amount) resources[tradingGoods.type] = (resources[tradingGoods.type] || 0) + (tradingGoods.amount.traderGain || 0) + (tradingGoods.amount.market1Gain || 0) + (tradingGoods.amount.market2Gain || 0); return resources; } /** * Remove filter prefix (mirage, corpse, etc) from template name. * * ie. filter|dir/to/template -> dir/to/template */ function removeFiltersFromTemplateName(templateName) { return templateName.split("|").pop(); } Index: ps/trunk/binaries/data/mods/public/gui/common/tooltips.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/common/tooltips.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/gui/common/tooltips.js (revision 22754) @@ -1,832 +1,832 @@ var g_TooltipTextFormats = { "unit": { "font": "sans-10", "color": "orange" }, "header": { "font": "sans-bold-13" }, "body": { "font": "sans-13" }, "comma": { "font": "sans-12" }, "nameSpecificBig": { "font": "sans-bold-16" }, "nameSpecificSmall": { "font": "sans-bold-12" }, "nameGeneric": { "font": "sans-bold-16" } }; var g_AttackTypes = { "Melee": translate("Melee Attack:"), "Ranged": translate("Ranged Attack:"), "Capture": translate("Capture Attack:") }; var g_SplashDamageTypes = { "Circular": translate("Circular Splash Damage"), "Linear": translate("Linear Splash Damage") }; var g_RangeTooltipString = { "relative": { // Translation: For example: Ranged Attack: 12.0 Pierce, Range: 2 to 10 (+2) meters, Interval: 3 arrows / 2 seconds "minRange": translate("%(attackLabel)s %(damageTypes)s, %(rangeLabel)s %(minRange)s to %(maxRange)s (%(relativeRange)s) %(rangeUnit)s, %(rate)s"), // Translation: For example: Ranged Attack: 12.0 Pierce, Range: 10 (+2) meters, Interval: 3 arrows / 2 seconds "no-minRange": translate("%(attackLabel)s %(damageTypes)s, %(rangeLabel)s %(maxRange)s (%(relativeRange)s) %(rangeUnit)s, %(rate)s"), }, "non-relative": { // Translation: For example: Ranged Attack: 12.0 Pierce, Range: 2 to 10 meters, Interval: 3 arrows / 2 seconds "minRange": translate("%(attackLabel)s %(damageTypes)s, %(rangeLabel)s %(minRange)s to %(maxRange)s %(rangeUnit)s, %(rate)s"), // Translation: For example: Ranged Attack: 12.0 Pierce, Range: 10 meters, Interval: 3 arrows / 2 seconds "no-minRange": translate("%(attackLabel)s %(damageTypes)s, %(rangeLabel)s %(maxRange)s %(rangeUnit)s, %(rate)s"), } }; function getCostTypes() { return g_ResourceData.GetCodes().concat(["population", "populationBonus", "time"]); } /** * If true, always shows whether the splash damage deals friendly fire. * Otherwise display the friendly fire tooltip only if it does. */ var g_AlwaysDisplayFriendlyFire = false; function resourceIcon(resource) { return '[icon="icon_' + resource + '"]'; } function resourceNameFirstWord(type) { return translateWithContext("firstWord", g_ResourceData.GetNames()[type]); } function resourceNameWithinSentence(type) { return translateWithContext("withinSentence", g_ResourceData.GetNames()[type]); } /** * Format resource amounts to proper english and translate (for example: "200 food, 100 wood and 300 metal"). */ function getLocalizedResourceAmounts(resources) { let amounts = g_ResourceData.GetCodes() .filter(type => !!resources[type]) .map(type => sprintf(translate("%(amount)s %(resourceType)s"), { "amount": resources[type], "resourceType": resourceNameWithinSentence(type) })); if (amounts.length < 2) return amounts.join(); let lastAmount = amounts.pop(); return sprintf(translate("%(previousAmounts)s and %(lastAmount)s"), { // Translation: This comma is used for separating first to penultimate elements in an enumeration. "previousAmounts": amounts.join(translate(", ")), "lastAmount": lastAmount }); } function bodyFont(text) { return setStringTags(text, g_TooltipTextFormats.body); } function headerFont(text) { return setStringTags(text, g_TooltipTextFormats.header); } function unitFont(text) { return setStringTags(text, g_TooltipTextFormats.unit); } function commaFont(text) { return setStringTags(text, g_TooltipTextFormats.comma); } function getSecondsString(seconds) { return sprintf(translatePlural("%(time)s %(second)s", "%(time)s %(second)s", seconds), { "time": seconds, "second": unitFont(translatePlural("second", "seconds", seconds)) }); } /** * Entity templates have a `Tooltip` tag in the Identity component. * (The contents of which are copied to a `tooltip` attribute in globalscripts.) * * Technologies have a `tooltip` attribute. */ function getEntityTooltip(template) { if (!template.tooltip) return ""; return bodyFont(template.tooltip); } /** * Technologies have a `description` attribute, and Auras have an `auraDescription` * attribute, which becomes `description`. * * (For technologies, this happens in globalscripts.) * * (For auras, this happens either in the Auras component (for session gui) or * reference/common/load.js (for Reference Suite gui)) */ function getDescriptionTooltip(template) { if (!template.description) return ""; return bodyFont(template.description); } /** * Entity templates have a `History` tag in the Identity component. * (The contents of which are copied to a `history` attribute in globalscripts.) */ function getHistoryTooltip(template) { if (!template.history) return ""; return bodyFont(template.history); } function getHealthTooltip(template) { if (!template.health) return ""; return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Health:")), "details": template.health }); } function getCurrentHealthTooltip(entState, label) { if (!entState.maxHitpoints) return ""; return sprintf(translate("%(healthLabel)s %(current)s / %(max)s"), { "healthLabel": headerFont(label || translate("Health:")), "current": Math.round(entState.hitpoints), "max": Math.round(entState.maxHitpoints) }); } function attackRateDetails(template, type) { // Either one arrow shot by UnitAI, let timeString = getSecondsString(template.attack[type].repeatTime / 1000); // or multiple arrows shot by BuildingAI if (!template.buildingAI || type != "Ranged") return timeString; // Show either current rate from simulation or default count if the sim is not running let arrows = template.buildingAI.arrowCount || template.buildingAI.defaultArrowCount; let arrowString = sprintf(translatePlural("%(arrowcount)s %(arrows)s", "%(arrowcount)s %(arrows)s", arrows), { "arrowcount": arrows, "arrows": unitFont(translatePlural("arrow", "arrows", arrows)) }); return sprintf(translate("%(arrowString)s / %(timeString)s"), { "arrowString": arrowString, "timeString": timeString }); } /** * Converts an armor level into the actual reduction percentage */ function armorLevelToPercentageString(level) { return sprintf(translate("%(percentage)s%%"), { "percentage": (100 - Math.round(Math.pow(0.9, level) * 100)) }); } function getArmorTooltip(template) { if (!template.armour) return ""; return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Armor:")), "details": Object.keys(template.armour).map( dmgType => sprintf(translate("%(damage)s %(damageType)s %(armorPercentage)s"), { "damage": template.armour[dmgType].toFixed(1), "damageType": unitFont(translateWithContext("damage type", dmgType)), "armorPercentage": '[font="sans-10"]' + sprintf(translate("(%(armorPercentage)s)"), { "armorPercentage": armorLevelToPercentageString(template.armour[dmgType]) }) + '[/font]' }) ).join(commaFont(translate(", "))) }); } function damageTypesToText(dmg) { if (!dmg) return '[font="sans-12"]' + translate("(None)") + '[/font]'; return Object.keys(dmg).filter(dmgType => dmg[dmgType]).map( dmgType => sprintf(translate("%(damage)s %(damageType)s"), { "damage": dmg[dmgType].toFixed(1), "damageType": unitFont(translateWithContext("damage type", dmgType)) })).join(commaFont(translate(", "))); } function getAttackTooltip(template) { if (!template.attack) return ""; let tooltips = []; for (let type in template.attack) { if (type == "Slaughter") continue; // Slaughter is used to kill animals, so do not show it. let rate = sprintf(translate("%(label)s %(details)s"), { "label": headerFont( template.buildingAI && type == "Ranged" ? translate("Interval:") : translate("Rate:")), "details": attackRateDetails(template, type) }); let attackLabel = headerFont(g_AttackTypes[type]); if (type == "Capture" || type != "Ranged") { tooltips.push(sprintf(translate("%(attackLabel)s %(details)s, %(rate)s"), { "attackLabel": attackLabel, "details": type == "Capture" ? - template.attack.Capture.value : - damageTypesToText(template.attack[type].damage), + template.attack.Capture.Capture : + damageTypesToText(template.attack[type].Damage), "rate": rate })); continue; } let minRange = Math.round(template.attack[type].minRange); let maxRange = Math.round(template.attack[type].maxRange); let realRange = template.attack[type].elevationAdaptedRange; let relativeRange = realRange ? Math.round(realRange - maxRange) : 0; tooltips.push(sprintf(g_RangeTooltipString[relativeRange ? "relative" : "non-relative"][minRange ? "minRange" : "no-minRange"], { "attackLabel": attackLabel, - "damageTypes": damageTypesToText(template.attack[type].damage), + "damageTypes": damageTypesToText(template.attack[type].Damage), "rangeLabel": headerFont(translate("Range:")), "minRange": minRange, "maxRange": maxRange, "relativeRange": relativeRange > 0 ? sprintf(translate("+%(number)s"), { "number": relativeRange }) : relativeRange, "rangeUnit": unitFont(minRange || relativeRange ? // Translation: For example "0.5 to 1 meters", "1 (+1) meters" or "1 to 2 (+3) meters" translate("meters") : translatePlural("meter", "meters", maxRange)), "rate": rate, })); } return tooltips.join("\n"); } function getSplashDamageTooltip(template) { if (!template.attack) return ""; let tooltips = []; for (let type in template.attack) { let splash = template.attack[type].splash; if (!splash) continue; let splashDamageTooltip = sprintf(translate("%(label)s: %(value)s"), { "label": headerFont(g_SplashDamageTypes[splash.shape]), - "value": damageTypesToText(splash.damage) + "value": damageTypesToText(splash.Damage) }); if (g_AlwaysDisplayFriendlyFire || splash.friendlyFire) splashDamageTooltip += commaFont(translate(", ")) + sprintf(translate("Friendly Fire: %(enabled)s"), { "enabled": splash.friendlyFire ? translate("Yes") : translate("No") }); tooltips.push(splashDamageTooltip); } // If multiple attack types deal splash damage, the attack type should be shown to differentiate. return tooltips.join("\n"); } function getGarrisonTooltip(template) { if (!template.garrisonHolder) return ""; let tooltips = [ sprintf(translate("%(label)s: %(garrisonLimit)s"), { "label": headerFont(translate("Garrison Limit")), "garrisonLimit": template.garrisonHolder.capacity }) ]; if (template.garrisonHolder.buffHeal) tooltips.push( sprintf(translate("%(healRateLabel)s %(value)s %(health)s / %(second)s"), { "healRateLabel": headerFont(translate("Heal:")), "value": Math.round(template.garrisonHolder.buffHeal), "health": unitFont(translate("Health")), "second": unitFont(translate("second")), }) ); return tooltips.join(commaFont(translate(", "))); } function getProjectilesTooltip(template) { if (!template.garrisonHolder || !template.buildingAI) return ""; let limit = Math.min( template.buildingAI.maxArrowCount || Infinity, template.buildingAI.defaultArrowCount + Math.round(template.buildingAI.garrisonArrowMultiplier * template.garrisonHolder.capacity) ); if (!limit) return ""; return [ sprintf(translate("%(label)s: %(value)s"), { "label": headerFont(translate("Projectile Limit")), "value": limit }), sprintf(translate("%(label)s: %(value)s"), { "label": headerFont(translateWithContext("projectiles", "Default")), "value": template.buildingAI.defaultArrowCount }), sprintf(translate("%(label)s: %(value)s"), { "label": headerFont(translateWithContext("projectiles", "Per Unit")), "value": +template.buildingAI.garrisonArrowMultiplier.toFixed(2) }) ].join(commaFont(translate(", "))); } function getRepairTimeTooltip(entState) { return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Number of repairers:")), "details": entState.repairable.numBuilders }) + "\n" + (entState.repairable.numBuilders ? sprintf(translatePlural( "Add another worker to speed up the repairs by %(second)s second.", "Add another worker to speed up the repairs by %(second)s seconds.", Math.round(entState.repairable.buildTime.timeRemaining - entState.repairable.buildTime.timeRemainingNew)), { "second": Math.round(entState.repairable.buildTime.timeRemaining - entState.repairable.buildTime.timeRemainingNew) }) : sprintf(translatePlural( "Add a worker to finish the repairs in %(second)s second.", "Add a worker to finish the repairs in %(second)s seconds.", Math.round(entState.repairable.buildTime.timeRemainingNew)), { "second": Math.round(entState.repairable.buildTime.timeRemainingNew) })); } function getBuildTimeTooltip(entState) { return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Number of builders:")), "details": entState.foundation.numBuilders }) + "\n" + (entState.foundation.numBuilders ? sprintf(translatePlural( "Add another worker to speed up the construction by %(second)s second.", "Add another worker to speed up the construction by %(second)s seconds.", Math.round(entState.foundation.buildTime.timeRemaining - entState.foundation.buildTime.timeRemainingNew)), { "second": Math.round(entState.foundation.buildTime.timeRemaining - entState.foundation.buildTime.timeRemainingNew) }) : sprintf(translatePlural( "Add a worker to finish the construction in %(second)s second.", "Add a worker to finish the construction in %(second)s seconds.", Math.round(entState.foundation.buildTime.timeRemainingNew)), { "second": Math.round(entState.foundation.buildTime.timeRemainingNew) })); } /** * Multiplies the costs for a template by a given batch size. */ function multiplyEntityCosts(template, trainNum) { let totalCosts = {}; for (let r of getCostTypes()) if (template.cost[r]) totalCosts[r] = Math.floor(template.cost[r] * trainNum); return totalCosts; } /** * Helper function for getEntityCostTooltip. */ function getEntityCostComponentsTooltipString(template, entity, buildingsCountToTrainFullBatch = 1, fullBatchSize = 1, remainderBatch = 0) { let totalCosts = multiplyEntityCosts(template, buildingsCountToTrainFullBatch * fullBatchSize + remainderBatch); if (template.cost.time) totalCosts.time = Math.ceil(template.cost.time * (entity ? Engine.GuiInterfaceCall("GetBatchTime", { "entity": entity, "batchSize": buildingsCountToTrainFullBatch > 0 ? fullBatchSize : remainderBatch }) : 1)); let costs = []; for (let type of getCostTypes()) // Population bonus is shown in the tooltip if (type != "populationBonus" && totalCosts[type]) costs.push(sprintf(translate("%(component)s %(cost)s"), { "component": resourceIcon(type), "cost": totalCosts[type] })); return costs; } function getGatherTooltip(template) { if (!template.resourceGatherRates) return ""; // Average the resource rates (TODO: distinguish between subtypes) let rates = {}; for (let resource of g_ResourceData.GetResources()) { let types = [resource.code]; for (let subtype in resource.subtypes) // We ignore ruins as those are not that common and skew the results if (subtype !== "ruins") types.push(resource.code + "." + subtype); let [rate, count] = types.reduce((sum, t) => { let r = template.resourceGatherRates[t]; return [sum[0] + (r > 0 ? r : 0), sum[1] + (r > 0 ? 1 : 0)]; }, [0, 0]); if (rate > 0) rates[resource.code] = +(rate / count).toFixed(1); } if (!Object.keys(rates).length) return ""; return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Gather Rates:")), "details": Object.keys(rates).map( type => sprintf(translate("%(resourceIcon)s %(rate)s"), { "resourceIcon": resourceIcon(type), "rate": rates[type] }) ).join(" ") }); } function getResourceTrickleTooltip(template) { if (!template.resourceTrickle) return ""; let resCodes = g_ResourceData.GetCodes().filter(res => !!template.resourceTrickle.rates[res]); if (!resCodes.length) return ""; return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Resource Trickle:")), "details": sprintf(translate("%(resources)s / %(time)s"), { "resources": resCodes.map( res => sprintf(translate("%(resourceIcon)s %(rate)s"), { "resourceIcon": resourceIcon(res), "rate": template.resourceTrickle.rates[res] }) ).join(" "), "time": getSecondsString(template.resourceTrickle.interval / 1000) }) }); } /** * Returns an array of strings for a set of wall pieces. If the pieces share * resource type requirements, output will be of the form '10 to 30 Stone', * otherwise output will be, e.g. '10 Stone, 20 Stone, 30 Stone'. */ function getWallPieceTooltip(wallTypes) { let out = []; let resourceCount = {}; for (let resource of getCostTypes()) if (wallTypes[0].cost[resource]) resourceCount[resource] = [wallTypes[0].cost[resource]]; let sameTypes = true; for (let i = 1; i < wallTypes.length; ++i) { for (let resource in wallTypes[i].cost) // Break out of the same-type mode if this wall requires // resource types that the first didn't. if (wallTypes[i].cost[resource] && !resourceCount[resource]) { sameTypes = false; break; } for (let resource in resourceCount) if (wallTypes[i].cost[resource]) resourceCount[resource].push(wallTypes[i].cost[resource]); else { sameTypes = false; break; } } if (sameTypes) for (let resource in resourceCount) // Translation: This string is part of the resources cost string on // the tooltip for wall structures. out.push(sprintf(translate("%(resourceIcon)s %(minimum)s to %(resourceIcon)s %(maximum)s"), { "resourceIcon": resourceIcon(resource), "minimum": Math.min.apply(Math, resourceCount[resource]), "maximum": Math.max.apply(Math, resourceCount[resource]) })); else for (let i = 0; i < wallTypes.length; ++i) out.push(getEntityCostComponentsTooltipString(wallTypes[i]).join(", ")); return out; } /** * Returns the cost information to display in the specified entity's construction button tooltip. */ function getEntityCostTooltip(template, entity, buildingsCountToTrainFullBatch, fullBatchSize, remainderBatch) { // Entities with a wallset component are proxies for initiating wall placement and as such do not have a cost of // their own; the individual wall pieces within it do. if (template.wallSet) { let templateLong = GetTemplateData(template.wallSet.templates.long); let templateMedium = GetTemplateData(template.wallSet.templates.medium); let templateShort = GetTemplateData(template.wallSet.templates.short); let templateTower = GetTemplateData(template.wallSet.templates.tower); let wallCosts = getWallPieceTooltip([templateShort, templateMedium, templateLong]); let towerCosts = getEntityCostComponentsTooltipString(templateTower); return sprintf(translate("Walls: %(costs)s"), { "costs": wallCosts.join(" ") }) + "\n" + sprintf(translate("Towers: %(costs)s"), { "costs": towerCosts.join(" ") }); } if (template.cost) { let costs = getEntityCostComponentsTooltipString(template, entity, buildingsCountToTrainFullBatch, fullBatchSize, remainderBatch).join(" "); if (costs) // Translation: Label in tooltip showing cost of a unit, structure or technology. return sprintf(translate("%(label)s %(costs)s"), { "label": headerFont(translate("Cost:")), "costs": costs }); } return ""; } function getRequiredTechnologyTooltip(technologyEnabled, requiredTechnology, civ) { if (technologyEnabled) return ""; return sprintf(translate("Requires %(technology)s"), { "technology": getEntityNames(GetTechnologyData(requiredTechnology, civ)) }); } /** * Returns the population bonus information to display in the specified entity's construction button tooltip. */ function getPopulationBonusTooltip(template) { let popBonus = ""; if (template.cost && template.cost.populationBonus) popBonus = sprintf(translate("%(label)s %(populationBonus)s"), { "label": headerFont(translate("Population Bonus:")), "populationBonus": template.cost.populationBonus }); return popBonus; } /** * Returns a message with the amount of each resource needed to create an entity. */ function getNeededResourcesTooltip(resources) { if (!resources) return ""; let formatted = []; for (let resource in resources) formatted.push(sprintf(translate("%(component)s %(cost)s"), { "component": '[font="sans-12"]' + resourceIcon(resource) + '[/font]', "cost": resources[resource] })); return coloredText( '[font="sans-bold-13"]' + translate("Insufficient resources:") + '[/font]', "red") + " " + formatted.join(" "); } function getSpeedTooltip(template) { if (!template.speed) return ""; let walk = template.speed.walk.toFixed(1); let run = template.speed.run.toFixed(1); if (walk == 0 && run == 0) return ""; return sprintf(translate("%(label)s %(speeds)s"), { "label": headerFont(translate("Speed:")), "speeds": sprintf(translate("%(speed)s %(movementType)s"), { "speed": walk, "movementType": unitFont(translate("Walk")) }) + commaFont(translate(", ")) + sprintf(translate("%(speed)s %(movementType)s"), { "speed": run, "movementType": unitFont(translate("Run")) }) }); } function getHealerTooltip(template) { if (!template.heal) return ""; let hp = +(template.heal.hp.toFixed(1)); let range = +(template.heal.range.toFixed(0)); let rate = +((template.heal.rate / 1000).toFixed(1)); return [ sprintf(translatePlural("%(label)s %(val)s %(unit)s", "%(label)s %(val)s %(unit)s", hp), { "label": headerFont(translate("Heal:")), "val": hp, // Translation: Short for hit points (or health points) that are healed in one healing action "unit": unitFont(translatePlural("HP", "HP", hp)) }), sprintf(translatePlural("%(label)s %(val)s %(unit)s", "%(label)s %(val)s %(unit)s", range), { "label": headerFont(translate("Range:")), "val": range, "unit": unitFont(translatePlural("meter", "meters", range)) }), sprintf(translatePlural("%(label)s %(val)s %(unit)s", "%(label)s %(val)s %(unit)s", rate), { "label": headerFont(translate("Rate:")), "val": rate, "unit": unitFont(translatePlural("second", "seconds", rate)) }) ].join(translate(", ")); } function getAurasTooltip(template) { let auras = template.auras || template.wallSet && GetTemplateData(template.wallSet.templates.long).auras; if (!auras) return ""; let tooltips = []; for (let auraID in auras) { let tooltip = sprintf(translate("%(auralabel)s %(aurainfo)s"), { "auralabel": headerFont(sprintf(translate("%(auraname)s:"), { "auraname": translate(auras[auraID].name) })), "aurainfo": bodyFont(translate(auras[auraID].description)) }); let radius = +auras[auraID].radius; if (radius) tooltip += " " + sprintf(translatePlural("%(label)s %(val)s %(unit)s", "%(label)s %(val)s %(unit)s", radius), { "label": translateWithContext("aura", "Range:"), "val": radius, "unit": unitFont(translatePlural("meter", "meters", radius)) }); tooltips.push(tooltip); } return tooltips.join("\n"); } function getEntityNames(template) { if (!template.name.specific) return template.name.generic; if (template.name.specific == template.name.generic) return template.name.specific; return sprintf(translate("%(specificName)s (%(genericName)s)"), { "specificName": template.name.specific, "genericName": template.name.generic }); } function getEntityNamesFormatted(template) { if (!template.name.specific) return setStringTags(template.name.generic, g_TooltipTextFormats.nameSpecificBig); // Translation: Example: "Epibátēs Athēnaîos [font="sans-bold-16"](Athenian Marine)[/font]" return sprintf(translate("%(specificName)s %(fontStart)s(%(genericName)s)%(fontEnd)s"), { "specificName": setStringTags(template.name.specific[0], g_TooltipTextFormats.nameSpecificBig) + setStringTags(template.name.specific.slice(1).toUpperCase(), g_TooltipTextFormats.nameSpecificSmall), "genericName": template.name.generic, "fontStart": '[font="' + g_TooltipTextFormats.nameGeneric.font + '"]', "fontEnd": '[/font]' }); } function getVisibleEntityClassesFormatted(template) { if (!template.visibleIdentityClasses || !template.visibleIdentityClasses.length) return ""; return headerFont(translate("Classes:")) + ' ' + bodyFont(template.visibleIdentityClasses.map(c => translate(c)).join(translate(", "))); } function getLootTooltip(template) { if (!template.loot && !template.resourceCarrying) return ""; let resourcesCarried = []; if (template.resourceCarrying) resourcesCarried = calculateCarriedResources( template.resourceCarrying, template.trader && template.trader.goods ); let lootLabels = []; for (let type of g_ResourceData.GetCodes().concat(["xp"])) { let loot = (template.loot && template.loot[type] || 0) + (resourcesCarried[type] || 0); if (!loot) continue; // Translation: %(component) will be the icon for the loot type and %(loot) will be the value. lootLabels.push(sprintf(translate("%(component)s %(loot)s"), { "component": resourceIcon(type), "loot": loot })); } if (!lootLabels.length) return ""; return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Loot:")), "details": lootLabels.join(" ") }); } function showTemplateViewerOnRightClickTooltip() { // Translation: Appears in a tooltip to indicate that right-clicking the corresponding GUI element will open the Template Details GUI page. return translate("Right-click to view more information."); } function showTemplateViewerOnClickTooltip() { // Translation: Appears in a tooltip to indicate that clicking the corresponding GUI element will open the Template Details GUI page. return translate("Click to view more information."); } Index: ps/trunk/binaries/data/mods/public/maps/random/polar_sea_triggers.js =================================================================== --- ps/trunk/binaries/data/mods/public/maps/random/polar_sea_triggers.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/maps/random/polar_sea_triggers.js (revision 22754) @@ -1,113 +1,112 @@ const debugLog = false; var attackerTemplate = "gaia/fauna_arctic_wolf_domestic"; var minWaveSize = 1; var maxWaveSize = 3; var firstWaveTime = 5; var minWaveTime = 2; var maxWaveTime = 4; /** * Attackers will focus the targetCount closest units that have the targetClasses type. */ var targetClasses = "Organic+!Domestic"; var targetCount = 3; var disabledTechnologies = [ "gather_lumbering_ironaxes", "gather_lumbering_sharpaxes", "gather_lumbering_strongeraxes" ]; Trigger.prototype.InitDisableTechnologies = function() { for (let i = 1; i < TriggerHelper.GetNumberOfPlayers(); ++i) QueryPlayerIDInterface(i).SetDisabledTechnologies(disabledTechnologies); }; Trigger.prototype.SpawnWolvesAndAttack = function() { let waveSize = Math.round(Math.random() * (maxWaveSize - minWaveSize) + minWaveSize); let attackers = TriggerHelper.SpawnUnitsFromTriggerPoints("A", attackerTemplate, waveSize, 0); if (debugLog) print("Spawned " + waveSize + " " + attackerTemplate + " at " + Object.keys(attackers).length + " points\n"); let allTargets; - let cmpDamage = Engine.QueryInterface(SYSTEM_ENTITY, IID_Damage); let players = new Array(TriggerHelper.GetNumberOfPlayers()).fill(0).map((v, i) => i + 1); for (let spawnPoint in attackers) { // TriggerHelper.SpawnUnits is guaranteed to spawn let firstAttacker = attackers[spawnPoint][0]; if (!firstAttacker) continue; let attackerPos = TriggerHelper.GetEntityPosition2D(firstAttacker); if (!attackerPos) continue; // The returned entities are sorted by RangeManager already - let targets = cmpDamage.EntitiesNearPoint(attackerPos, 200, players).filter(ent => { + let targets = Attack.EntitiesNearPoint(attackerPos, 200, players).filter(ent => { let cmpIdentity = Engine.QueryInterface(ent, IID_Identity); return cmpIdentity && MatchesClassList(cmpIdentity.GetClassesList(), targetClasses); }); let goodTargets = targets.slice(0, targetCount); // Look through all targets if there aren't enough nearby ones if (goodTargets.length < targetCount) { if (!allTargets) allTargets = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).GetNonGaiaEntities().filter(ent => { let cmpIdentity = Engine.QueryInterface(ent, IID_Identity); return cmpIdentity && MatchesClassList(cmpIdentity.GetClassesList(), targetClasses); }); let getDistance = target => { let targetPos = TriggerHelper.GetEntityPosition2D(target); return targetPos ? attackerPos.distanceToSquared(targetPos) : Infinity; }; goodTargets = []; let goodDists = []; for (let target of allTargets) { let dist = getDistance(target); let i = goodDists.findIndex(element => dist < element); if (i != -1 || goodTargets.length < targetCount) { if (i == -1) i = goodTargets.length; goodTargets.splice(i, 0, target); goodDists.splice(i, 0, dist); if (goodTargets.length > targetCount) { goodTargets.pop(); goodDists.pop(); } } } } for (let target of goodTargets) ProcessCommand(0, { "type": "attack", "entities": attackers[spawnPoint], "target": target, "queued": true }); } this.DoAfterDelay((Math.random() * (maxWaveTime - minWaveTime) + minWaveTime) * 60 * 1000, "SpawnWolvesAndAttack", {}); }; { let cmpTrigger = Engine.QueryInterface(SYSTEM_ENTITY, IID_Trigger); cmpTrigger.InitDisableTechnologies(); cmpTrigger.DoAfterDelay(firstWaveTime * 60 * 1000, "SpawnWolvesAndAttack", {}); } Index: ps/trunk/binaries/data/mods/public/maps/random/survivalofthefittest_triggers.js =================================================================== --- ps/trunk/binaries/data/mods/public/maps/random/survivalofthefittest_triggers.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/maps/random/survivalofthefittest_triggers.js (revision 22754) @@ -1,308 +1,308 @@ /** * If set to true, it will print how many templates would be spawned if the players were not defeated. */ const dryRun = false; /** * If enabled, prints the number of units to the command line output. */ const debugLog = false; /** * Get the number of minutes to pass between spawning new treasures. */ var treasureTime = () => randFloat(3, 5); /** * Get the time in minutes when the first wave of attackers will be spawned. */ var firstWaveTime = () => randFloat(4, 6); /** * Maximum time in minutes between two consecutive waves. */ var maxWaveTime = 4; /** * Get the next attacker wave delay. */ var waveTime = () => randFloat(0.5, 1) * maxWaveTime; /** * Roughly the number of attackers on the first wave. */ var initialAttackers = 5; /** * Increase the number of attackers exponentially, by this percent value per minute. */ var percentPerMinute = 1.05; /** * Greatest amount of attackers that can be spawned. */ var totalAttackerLimit = 200; /** * Least and greatest amount of siege engines per wave. */ var siegeFraction = () => randFloat(0.2, 0.5); /** * Potentially / definitely spawn a gaia hero after this number of minutes. */ var heroTime = () => randFloat(20, 60); /** * The following templates can't be built by any player. */ var disabledTemplates = (civ) => [ // Economic structures "structures/" + civ + "_corral", "structures/" + civ + "_farmstead", "structures/" + civ + "_field", "structures/" + civ + "_storehouse", "structures/" + civ + "_rotarymill", "units/maur_support_elephant", // Expansions "structures/" + civ + "_civil_centre", "structures/" + civ + "_military_colony", // Walls "structures/" + civ + "_wallset_stone", "structures/rome_wallset_siege", "other/wallset_palisade", // Shoreline "structures/" + civ + "_dock", "structures/brit_crannog", "structures/cart_super_dock", "structures/ptol_lighthouse" ]; /** * Spawn these treasures in regular intervals. */ var treasures = [ "gaia/treasure/food_barrel", "gaia/treasure/food_bin", "gaia/treasure/food_crate", "gaia/treasure/food_jars", "gaia/treasure/metal", "gaia/treasure/stone", "gaia/treasure/wood", "gaia/treasure/wood", "gaia/treasure/wood" ]; /** * An object that maps from civ [f.e. "spart"] to an object * that has the keys "champions", "siege" and "heroes", * which is an array containing all these templates, * trainable from a building or not. */ var attackerUnitTemplates = {}; Trigger.prototype.InitSurvival = function() { this.InitStartingUnits(); this.LoadAttackerTemplates(); this.SetDisableTemplates(); this.PlaceTreasures(); this.InitializeEnemyWaves(); }; Trigger.prototype.debugLog = function(txt) { if (!debugLog) return; print("DEBUG [" + Math.round(TriggerHelper.GetMinutes()) + "] " + txt + "\n"); }; Trigger.prototype.LoadAttackerTemplates = function() { for (let civ of ["gaia", ...Object.keys(loadCivFiles(false))]) attackerUnitTemplates[civ] = { "heroes": TriggerHelper.GetTemplateNamesByClasses("Hero", civ, undefined, true), "champions": TriggerHelper.GetTemplateNamesByClasses("Champion+!Elephant", civ, undefined, true), "siege": TriggerHelper.GetTemplateNamesByClasses("Siege Champion+Elephant", civ, "packed", undefined) }; this.debugLog("Attacker templates:"); this.debugLog(uneval(attackerUnitTemplates)); }; Trigger.prototype.SetDisableTemplates = function() { for (let i = 1; i < TriggerHelper.GetNumberOfPlayers(); ++i) { let cmpPlayer = QueryPlayerIDInterface(i); cmpPlayer.SetDisabledTemplates(disabledTemplates(cmpPlayer.GetCiv())); } }; /** * Remember civic centers and make women invincible. */ Trigger.prototype.InitStartingUnits = function() { for (let playerID = 1; playerID < TriggerHelper.GetNumberOfPlayers(); ++playerID) { this.playerCivicCenter[playerID] = TriggerHelper.GetPlayerEntitiesByClass(playerID, "CivilCentre")[0]; this.treasureFemale[playerID] = TriggerHelper.GetPlayerEntitiesByClass(playerID, "FemaleCitizen")[0]; - Engine.QueryInterface(this.treasureFemale[playerID], IID_DamageReceiver).SetInvulnerability(true); + Engine.QueryInterface(this.treasureFemale[playerID], IID_Resistance).SetInvulnerability(true); } }; Trigger.prototype.InitializeEnemyWaves = function() { let time = firstWaveTime() * 60 * 1000; Engine.QueryInterface(SYSTEM_ENTITY, IID_GuiInterface).AddTimeNotification({ "message": markForTranslation("The first wave will start in %(time)s!"), "translateMessage": true }, time); this.DoAfterDelay(time, "StartAnEnemyWave", {}); }; Trigger.prototype.StartAnEnemyWave = function() { let currentMin = TriggerHelper.GetMinutes(); let nextWaveTime = waveTime(); let civ = pickRandom(Object.keys(attackerUnitTemplates)); // Determine total attacker count of the current wave. // Exponential increase with time, capped to the limit and fluctuating proportionally with the current wavetime. let totalAttackers = Math.ceil(Math.min(totalAttackerLimit, initialAttackers * Math.pow(percentPerMinute, currentMin) * nextWaveTime / maxWaveTime)); let siegeRatio = siegeFraction(); this.debugLog("Spawning " + totalAttackers + " attackers, siege ratio " + siegeRatio.toFixed(2)); let attackerCount = TriggerHelper.BalancedTemplateComposition( [ { "templates": attackerUnitTemplates[civ].heroes, "count": currentMin > heroTime() && attackerUnitTemplates[civ].heroes.length ? 1 : 0 }, { "templates": attackerUnitTemplates[civ].siege, "frequency": siegeRatio }, { "templates": attackerUnitTemplates[civ].champions, "frequency": 1 - siegeRatio } ], totalAttackers); this.debugLog("Templates: " + uneval(attackerCount)); // Spawn the templates let spawned = false; for (let point of this.GetTriggerPoints("A")) { if (dryRun) { spawned = true; break; } // Don't spawn attackers for defeated players and players that lost their cc after win let playerID = QueryOwnerInterface(point, IID_Player).GetPlayerID(); let civicCentre = this.playerCivicCenter[playerID]; if (!civicCentre) continue; // Check if the cc is garrisoned in another building let targetPos = TriggerHelper.GetEntityPosition2D(civicCentre); if (!targetPos) continue; for (let templateName in attackerCount) { let isHero = attackerUnitTemplates[civ].heroes.indexOf(templateName) != -1; // Don't spawn gaia hero if the previous one is still alive if (this.gaiaHeroes[playerID] && isHero) { let cmpHealth = Engine.QueryInterface(this.gaiaHeroes[playerID], IID_Health); if (cmpHealth && cmpHealth.GetHitpoints() != 0) { this.debugLog("Not spawning hero for player " + playerID + " as the previous one is still alive"); continue; } } if (dryRun) continue; let entities = TriggerHelper.SpawnUnits(point, templateName, attackerCount[templateName], 0); ProcessCommand(0, { "type": "attack-walk", "entities": entities, "x": targetPos.x, "z": targetPos.y, "targetClasses": undefined, "allowCapture": false, "queued": true }); if (isHero) this.gaiaHeroes[playerID] = entities[0]; } spawned = true; } if (!spawned) return; Engine.QueryInterface(SYSTEM_ENTITY, IID_GuiInterface).PushNotification({ "message": markForTranslation("An enemy wave is attacking!"), "translateMessage": true }); this.DoAfterDelay(nextWaveTime * 60 * 1000, "StartAnEnemyWave", {}); }; Trigger.prototype.PlaceTreasures = function() { let point = pickRandom(["B", "C", "D"]); let triggerPoints = this.GetTriggerPoints(point); for (let point of triggerPoints) TriggerHelper.SpawnUnits(point, pickRandom(treasures), 1, 0); this.DoAfterDelay(treasureTime() * 60 * 1000, "PlaceTreasures", {}); }; Trigger.prototype.OnOwnershipChanged = function(data) { if (data.entity == this.playerCivicCenter[data.from]) { this.playerCivicCenter[data.from] = undefined; TriggerHelper.DefeatPlayer( data.from, markForTranslation("%(player)s has been defeated (lost civic center).")); } else if (data.entity == this.treasureFemale[data.from]) { this.treasureFemale[data.from] = undefined; Engine.DestroyEntity(data.entity); } }; { let cmpTrigger = Engine.QueryInterface(SYSTEM_ENTITY, IID_Trigger); cmpTrigger.treasureFemale = []; cmpTrigger.playerCivicCenter = []; cmpTrigger.gaiaHeroes = []; cmpTrigger.RegisterTrigger("OnInitGame", "InitSurvival", { "enabled": true }); cmpTrigger.RegisterTrigger("OnOwnershipChanged", "OnOwnershipChanged", { "enabled": true }); } Index: ps/trunk/binaries/data/mods/public/maps/scripts/CaptureTheRelic.js =================================================================== --- ps/trunk/binaries/data/mods/public/maps/scripts/CaptureTheRelic.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/maps/scripts/CaptureTheRelic.js (revision 22754) @@ -1,190 +1,190 @@ Trigger.prototype.InitCaptureTheRelic = function() { let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); let catafalqueTemplates = shuffleArray(cmpTemplateManager.FindAllTemplates(false).filter( name => GetIdentityClasses(cmpTemplateManager.GetTemplate(name).Identity || {}).indexOf("Relic") != -1)); let potentialSpawnPoints = TriggerHelper.GetLandSpawnPoints(); if (!potentialSpawnPoints.length) { error("No gaia entities found on this map that could be used as spawn points!"); return; } let cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager); let numSpawnedRelics = cmpEndGameManager.GetGameSettings().relicCount; this.playerRelicsCount = new Array(TriggerHelper.GetNumberOfPlayers()).fill(0, 1); this.playerRelicsCount[0] = numSpawnedRelics; for (let i = 0; i < numSpawnedRelics; ++i) { this.relics[i] = TriggerHelper.SpawnUnits(pickRandom(potentialSpawnPoints), catafalqueTemplates[i], 1, 0)[0]; - let cmpDamageReceiver = Engine.QueryInterface(this.relics[i], IID_DamageReceiver); - cmpDamageReceiver.SetInvulnerability(true); + let cmpResistance = Engine.QueryInterface(this.relics[i], IID_Resistance); + cmpResistance.SetInvulnerability(true); let cmpPositionRelic = Engine.QueryInterface(this.relics[i], IID_Position); cmpPositionRelic.SetYRotation(randomAngle()); } }; Trigger.prototype.CheckCaptureTheRelicVictory = function(data) { let cmpIdentity = Engine.QueryInterface(data.entity, IID_Identity); if (!cmpIdentity || !cmpIdentity.HasClass("Relic") || data.from == INVALID_PLAYER) return; --this.playerRelicsCount[data.from]; if (data.to == -1) { warn("Relic entity " + data.entity + " has been destroyed"); this.relics.splice(this.relics.indexOf(data.entity), 1); } else ++this.playerRelicsCount[data.to]; this.DeleteCaptureTheRelicVictoryMessages(); this.CheckCaptureTheRelicCountdown(); }; /** * Check if a group of mutually allied players have acquired all relics. * The winning players are the relic owners and all players mutually allied to all relic owners. * Reset the countdown if the group of winning players changes or extends. */ Trigger.prototype.CheckCaptureTheRelicCountdown = function() { if (this.playerRelicsCount[0]) { this.DeleteCaptureTheRelicVictoryMessages(); return; } let activePlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetActivePlayers(); let relicOwners = activePlayers.filter(playerID => this.playerRelicsCount[playerID]); if (!relicOwners.length) { this.DeleteCaptureTheRelicVictoryMessages(); return; } let winningPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager).GetAlliedVictory() ? activePlayers.filter(playerID => relicOwners.every(owner => QueryPlayerIDInterface(playerID).IsMutualAlly(owner))) : [relicOwners[0]]; // All relicOwners should be mutually allied if (relicOwners.some(owner => winningPlayers.indexOf(owner) == -1)) { this.DeleteCaptureTheRelicVictoryMessages(); return; } // Reset the timer when playerAndAllies isn't the same as this.relicsVictoryCountdownPlayers if (winningPlayers.length != this.relicsVictoryCountdownPlayers.length || winningPlayers.some(player => this.relicsVictoryCountdownPlayers.indexOf(player) == -1)) { this.relicsVictoryCountdownPlayers = winningPlayers; this.StartCaptureTheRelicCountdown(winningPlayers); } }; Trigger.prototype.DeleteCaptureTheRelicVictoryMessages = function() { if (!this.relicsVictoryTimer) return; Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer).CancelTimer(this.relicsVictoryTimer); this.relicsVictoryTimer = undefined; let cmpGuiInterface = Engine.QueryInterface(SYSTEM_ENTITY, IID_GuiInterface); cmpGuiInterface.DeleteTimeNotification(this.ownRelicsVictoryMessage); cmpGuiInterface.DeleteTimeNotification(this.othersRelicsVictoryMessage); this.relicsVictoryCountdownPlayers = []; }; Trigger.prototype.StartCaptureTheRelicCountdown = function(winningPlayers) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); let cmpGuiInterface = Engine.QueryInterface(SYSTEM_ENTITY, IID_GuiInterface); if (this.relicsVictoryTimer) { cmpTimer.CancelTimer(this.relicsVictoryTimer); cmpGuiInterface.DeleteTimeNotification(this.ownRelicsVictoryMessage); cmpGuiInterface.DeleteTimeNotification(this.othersRelicsVictoryMessage); } if (!this.relics.length) return; let others = [-1]; for (let playerID = 1; playerID < TriggerHelper.GetNumberOfPlayers(); ++playerID) { let cmpPlayer = QueryPlayerIDInterface(playerID); if (cmpPlayer.GetState() == "won") return; if (winningPlayers.indexOf(playerID) == -1) others.push(playerID); } let cmpPlayer = QueryOwnerInterface(this.relics[0], IID_Player); let cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager); let captureTheRelicDuration = cmpEndGameManager.GetGameSettings().relicDuration; let isTeam = winningPlayers.length > 1; this.ownRelicsVictoryMessage = cmpGuiInterface.AddTimeNotification({ "message": isTeam ? markForTranslation("%(_player_)s and their allies have captured all relics and will win in %(time)s.") : markForTranslation("%(_player_)s has captured all relics and will win in %(time)s."), "players": others, "parameters": { "_player_": cmpPlayer.GetPlayerID() }, "translateMessage": true, "translateParameters": [] }, captureTheRelicDuration); this.othersRelicsVictoryMessage = cmpGuiInterface.AddTimeNotification({ "message": isTeam ? markForTranslation("You and your allies have captured all relics and will win in %(time)s.") : markForTranslation("You have captured all relics and will win in %(time)s."), "players": winningPlayers, "translateMessage": true }, captureTheRelicDuration); this.relicsVictoryTimer = cmpTimer.SetTimeout(SYSTEM_ENTITY, IID_Trigger, "CaptureTheRelicVictorySetWinner", captureTheRelicDuration, winningPlayers); }; Trigger.prototype.CaptureTheRelicVictorySetWinner = function(winningPlayers) { let cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager); cmpEndGameManager.MarkPlayersAsWon( winningPlayers, n => markForPluralTranslation( "%(lastPlayer)s has won (Capture the Relic).", "%(players)s and %(lastPlayer)s have won (Capture the Relic).", n), n => markForPluralTranslation( "%(lastPlayer)s has been defeated (Capture the Relic).", "%(players)s and %(lastPlayer)s have been defeated (Capture the Relic).", n)); }; { let cmpTrigger = Engine.QueryInterface(SYSTEM_ENTITY, IID_Trigger); cmpTrigger.relics = []; cmpTrigger.playerRelicsCount = []; cmpTrigger.relicsVictoryTimer = undefined; cmpTrigger.ownRelicsVictoryMessage = undefined; cmpTrigger.othersRelicsVictoryMessage = undefined; cmpTrigger.relicsVictoryCountdownPlayers = []; cmpTrigger.DoAfterDelay(0, "InitCaptureTheRelic", {}); cmpTrigger.RegisterTrigger("OnDiplomacyChanged", "CheckCaptureTheRelicCountdown", { "enabled": true }); cmpTrigger.RegisterTrigger("OnOwnershipChanged", "CheckCaptureTheRelicVictory", { "enabled": true }); cmpTrigger.RegisterTrigger("OnPlayerWon", "DeleteCaptureTheRelicVictoryMessages", { "enabled": true }); cmpTrigger.RegisterTrigger("OnPlayerDefeated", "CheckCaptureTheRelicCountdown", { "enabled": true }); } Index: ps/trunk/binaries/data/mods/public/simulation/ai/common-api/entity.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/ai/common-api/entity.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/ai/common-api/entity.js (revision 22754) @@ -1,948 +1,948 @@ var API3 = function(m) { // defines a template. m.Template = m.Class({ "_init": function(sharedAI, templateName, template) { this._templateName = templateName; this._template = template; // save a reference to the template tech modifications if (!sharedAI._templatesModifications[this._templateName]) sharedAI._templatesModifications[this._templateName] = {}; this._templateModif = sharedAI._templatesModifications[this._templateName]; this._tpCache = new Map(); }, // helper function to return a template value, optionally adjusting for tech. // TODO: there's no support for "_string" values here. "get": function(string) { let value = this._template; if (this._entityModif && this._entityModif.has(string)) return this._entityModif.get(string); else if (this._templateModif) { let owner = this._entity ? this._entity.owner : PlayerID; if (this._templateModif[owner] && this._templateModif[owner].has(string)) return this._templateModif[owner].get(string); } if (!this._tpCache.has(string)) { let args = string.split("/"); for (let arg of args) { if (value[arg]) value = value[arg]; else { value = undefined; break; } } this._tpCache.set(string, value); } return this._tpCache.get(string); }, "templateName": function() { return this._templateName; }, "genericName": function() { return this.get("Identity/GenericName"); }, "civ": function() { return this.get("Identity/Civ"); }, "classes": function() { let template = this.get("Identity"); if (!template) return undefined; return GetIdentityClasses(template); }, "hasClass": function(name) { if (!this._classes) this._classes = this.classes(); let classes = this._classes; return classes && classes.indexOf(name) != -1; }, "hasClasses": function(array) { if (!this._classes) this._classes = this.classes(); let classes = this._classes; if (!classes) return false; for (let cls of array) if (classes.indexOf(cls) == -1) return false; return true; }, "requiredTech": function() { return this.get("Identity/RequiredTechnology"); }, "available": function(gameState) { let techRequired = this.requiredTech(); if (!techRequired) return true; return gameState.isResearched(techRequired); }, // specifically "phase": function() { let techRequired = this.requiredTech(); if (!techRequired) return 0; if (techRequired == "phase_village") return 1; if (techRequired == "phase_town") return 2; if (techRequired == "phase_city") return 3; if (techRequired.startsWith("phase_")) return 4; return 0; }, "cost": function(productionQueue) { if (!this.get("Cost")) return undefined; let ret = {}; for (let type in this.get("Cost/Resources")) ret[type] = +this.get("Cost/Resources/" + type); return ret; }, "costSum": function(productionQueue) { let cost = this.cost(productionQueue); if (!cost) return undefined; let ret = 0; for (let type in cost) ret += cost[type]; return ret; }, "techCostMultiplier": function(type) { return +(this.get("ProductionQueue/TechCostMultiplier/"+type) || 1); }, /** * Returns { "max": max, "min": min } or undefined if no obstruction. * max: radius of the outer circle surrounding this entity's obstruction shape * min: radius of the inner circle */ "obstructionRadius": function() { if (!this.get("Obstruction")) return undefined; if (this.get("Obstruction/Static")) { let w = +this.get("Obstruction/Static/@width"); let h = +this.get("Obstruction/Static/@depth"); return { "max": Math.sqrt(w*w + h*h) / 2, "min": Math.min(h, w) / 2 }; } if (this.get("Obstruction/Unit")) { let r = +this.get("Obstruction/Unit/@radius"); return { "max": r, "min": r }; } let right = this.get("Obstruction/Obstructions/Right"); let left = this.get("Obstruction/Obstructions/Left"); if (left && right) { let w = +right["@x"] + right["@width"]/2 - left["@x"] + left["@width"]/2; let h = Math.max(+right["@z"] + right["@depth"]/2, +left["@z"] + left["@depth"]/2) - Math.min(+right["@z"] - right["@depth"]/2, +left["@z"] - left["@depth"]/2); return { "max": Math.sqrt(w*w + h*h) / 2, "min": Math.min(h, w) / 2 }; } return { "max": 0, "min": 0 }; // Units have currently no obstructions }, /** * Returns the radius of a circle surrounding this entity's footprint. */ "footprintRadius": function() { if (!this.get("Footprint")) return undefined; if (this.get("Footprint/Square")) { let w = +this.get("Footprint/Square/@width"); let h = +this.get("Footprint/Square/@depth"); return Math.sqrt(w*w + h*h) / 2; } if (this.get("Footprint/Circle")) return +this.get("Footprint/Circle/@radius"); return 0; // this should never happen }, "maxHitpoints": function() { return +(this.get("Health/Max") || 0); }, "isHealable": function() { if (this.get("Health") !== undefined) return this.get("Health/Unhealable") !== "true"; return false; }, "isRepairable": function() { return this.get("Repairable") !== undefined; }, "getPopulationBonus": function() { return +this.get("Cost/PopulationBonus"); }, "armourStrengths": function() { let armourDamageTypes = this.get("Armour"); if (!armourDamageTypes) return undefined; let armour = {}; for (let damageType in armourDamageTypes) if (damageType != "Foundation") armour[damageType] = +armourDamageTypes[damageType]; return armour; }, "attackTypes": function() { if (!this.get("Attack")) return undefined; let ret = []; for (let type in this.get("Attack")) ret.push(type); return ret; }, "attackRange": function(type) { if (!this.get("Attack/" + type +"")) return undefined; return { "max": +this.get("Attack/" + type +"/MaxRange"), "min": +(this.get("Attack/" + type +"/MinRange") || 0) }; }, "attackStrengths": function(type) { let attackDamageTypes = this.get("Attack/" + type + "/Damage"); if (!attackDamageTypes) return undefined; let damage = {}; for (let damageType in attackDamageTypes) damage[damageType] = +attackDamageTypes[damageType]; return damage; }, "captureStrength": function() { if (!this.get("Attack/Capture")) return undefined; - return +this.get("Attack/Capture/Value") || 0; + return +this.get("Attack/Capture/Capture") || 0; }, "attackTimes": function(type) { if (!this.get("Attack/" + type +"")) return undefined; return { "prepare": +(this.get("Attack/" + type + "/PrepareTime") || 0), "repeat": +(this.get("Attack/" + type + "/RepeatTime") || 1000) }; }, // returns the classes this templates counters: // Return type is [ [-neededClasses- , multiplier], … ]. "getCounteredClasses": function() { if (!this.get("Attack")) return undefined; let Classes = []; for (let type in this.get("Attack")) { let bonuses = this.get("Attack/" + type + "/Bonuses"); if (!bonuses) continue; for (let b in bonuses) { let bonusClasses = this.get("Attack/" + type + "/Bonuses/" + b + "/Classes"); if (bonusClasses) Classes.push([bonusClasses.split(" "), +this.get("Attack/" + type +"/Bonuses/" + b +"/Multiplier")]); } } return Classes; }, // returns true if the entity counters those classes. // TODO: refine using the multiplier "countersClasses": function(classes) { if (!this.get("Attack")) return false; let mcounter = []; for (let type in this.get("Attack")) { let bonuses = this.get("Attack/" + type + "/Bonuses"); if (!bonuses) continue; for (let b in bonuses) { let bonusClasses = this.get("Attack/" + type + "/Bonuses/" + b + "/Classes"); if (bonusClasses) mcounter.concat(bonusClasses.split(" ")); } } for (let i in classes) if (mcounter.indexOf(classes[i]) != -1) return true; return false; }, // returns, if it exists, the multiplier from each attack against a given class "getMultiplierAgainst": function(type, againstClass) { if (!this.get("Attack/" + type +"")) return undefined; if (this.get("Attack/" + type + "/Bonuses")) { for (let b in this.get("Attack/" + type + "/Bonuses")) { let bonusClasses = this.get("Attack/" + type + "/Bonuses/" + b + "/Classes"); if (!bonusClasses) continue; for (let bcl of bonusClasses.split(" ")) if (bcl == againstClass) return +this.get("Attack/" + type + "/Bonuses/" + b + "/Multiplier"); } } return 1; }, "buildableEntities": function(civ) { let templates = this.get("Builder/Entities/_string"); if (!templates) return []; return templates.replace(/\{native\}/g, this.civ()).replace(/\{civ\}/g, civ).split(/\s+/); }, "trainableEntities": function(civ) { let templates = this.get("ProductionQueue/Entities/_string"); if (!templates) return undefined; return templates.replace(/\{native\}/g, this.civ()).replace(/\{civ\}/g, civ).split(/\s+/); }, "researchableTechs": function(gameState, civ) { let templates = this.get("ProductionQueue/Technologies/_string"); if (!templates) return undefined; let techs = templates.split(/\s+/); for (let i = 0; i < techs.length; ++i) { let tech = techs[i]; if (tech.indexOf("{civ}") == -1) continue; let civTech = tech.replace("{civ}", civ); techs[i] = TechnologyTemplates.Has(civTech) ? civTech : tech.replace("{civ}", "generic"); } return techs; }, "resourceSupplyType": function() { if (!this.get("ResourceSupply")) return undefined; let [type, subtype] = this.get("ResourceSupply/Type").split('.'); return { "generic": type, "specific": subtype }; }, // will return either "food", "wood", "stone", "metal" and not treasure. "getResourceType": function() { if (!this.get("ResourceSupply")) return undefined; let [type, subtype] = this.get("ResourceSupply/Type").split('.'); if (type == "treasure") return subtype; return type; }, "getDiminishingReturns": function() { return +(this.get("ResourceSupply/DiminishingReturns") || 1); }, "resourceSupplyMax": function() { return +this.get("ResourceSupply/Amount"); }, "maxGatherers": function() { return +(this.get("ResourceSupply/MaxGatherers") || 0); }, "resourceGatherRates": function() { if (!this.get("ResourceGatherer")) return undefined; let ret = {}; let baseSpeed = +this.get("ResourceGatherer/BaseSpeed"); for (let r in this.get("ResourceGatherer/Rates")) ret[r] = +this.get("ResourceGatherer/Rates/" + r) * baseSpeed; return ret; }, "resourceDropsiteTypes": function() { if (!this.get("ResourceDropsite")) return undefined; let types = this.get("ResourceDropsite/Types"); return types ? types.split(/\s+/) : []; }, "garrisonableClasses": function() { return this.get("GarrisonHolder/List/_string"); }, "garrisonMax": function() { return this.get("GarrisonHolder/Max"); }, "garrisonEjectHealth": function() { return +this.get("GarrisonHolder/EjectHealth"); }, "getDefaultArrow": function() { return +this.get("BuildingAI/DefaultArrowCount"); }, "getArrowMultiplier": function() { return +this.get("BuildingAI/GarrisonArrowMultiplier"); }, "getGarrisonArrowClasses": function() { if (!this.get("BuildingAI")) return undefined; return this.get("BuildingAI/GarrisonArrowClasses").split(/\s+/); }, "buffHeal": function() { return +this.get("GarrisonHolder/BuffHeal"); }, "promotion": function() { return this.get("Promotion/Entity"); }, "isPackable": function() { return this.get("Pack") != undefined; }, /** * Returns whether this is an animal that is too difficult to hunt. */ "isHuntable": function() { if(!this.get("ResourceSupply/KillBeforeGather")) return false; // do not hunt retaliating animals (animals without UnitAI are dead animals) let behaviour = this.get("UnitAI/NaturalBehaviour"); return !behaviour || behaviour != "violent" && behaviour != "aggressive" && behaviour != "defensive"; }, "walkSpeed": function() { return +this.get("UnitMotion/WalkSpeed"); }, "trainingCategory": function() { return this.get("TrainingRestrictions/Category"); }, "buildTime": function(productionQueue) { let time = +this.get("Cost/BuildTime"); if (productionQueue) time *= productionQueue.techCostMultiplier("time"); return time; }, "buildCategory": function() { return this.get("BuildRestrictions/Category"); }, "buildDistance": function() { let distance = this.get("BuildRestrictions/Distance"); if (!distance) return undefined; let ret = {}; for (let key in distance) ret[key] = this.get("BuildRestrictions/Distance/" + key); return ret; }, "buildPlacementType": function() { return this.get("BuildRestrictions/PlacementType"); }, "buildTerritories": function() { if (!this.get("BuildRestrictions") || !this.get("BuildRestrictions/Territory")) return undefined; return this.get("BuildRestrictions/Territory").split(/\s+/); }, "hasBuildTerritory": function(territory) { let territories = this.buildTerritories(); return territories && territories.indexOf(territory) != -1; }, "hasTerritoryInfluence": function() { return this.get("TerritoryInfluence") !== undefined; }, "hasDefensiveFire": function() { if (!this.get("Attack/Ranged")) return false; return this.getDefaultArrow() || this.getArrowMultiplier(); }, "territoryInfluenceRadius": function() { if (this.get("TerritoryInfluence") !== undefined) return +this.get("TerritoryInfluence/Radius"); return -1; }, "territoryInfluenceWeight": function() { if (this.get("TerritoryInfluence") !== undefined) return +this.get("TerritoryInfluence/Weight"); return -1; }, "territoryDecayRate": function() { return +(this.get("TerritoryDecay/DecayRate") || 0); }, "defaultRegenRate": function() { return +(this.get("Capturable/RegenRate") || 0); }, "garrisonRegenRate": function() { return +(this.get("Capturable/GarrisonRegenRate") || 0); }, "visionRange": function() { return +this.get("Vision/Range"); }, "gainMultiplier": function() { return +this.get("Trader/GainMultiplier"); }, "isBuilder": function() { return this.get("Builder") !== undefined; }, "isGatherer": function() { return this.get("ResourceGatherer") !== undefined; }, "canGather": function(type) { let gatherRates = this.get("ResourceGatherer/Rates"); if (!gatherRates) return false; for (let r in gatherRates) if (r.split('.')[0] === type) return true; return false; }, "isGarrisonHolder": function() { return this.get("GarrisonHolder") !== undefined; }, /** * returns true if the tempalte can capture the given target entity * if no target is given, returns true if the template has the Capture attack */ "canCapture": function(target) { if (!this.get("Attack/Capture")) return false; if (!target) return true; if (!target.get("Capturable")) return false; let restrictedClasses = this.get("Attack/Capture/RestrictedClasses/_string"); return !restrictedClasses || !MatchesClassList(target.classes(), restrictedClasses); }, "isCapturable": function() { return this.get("Capturable") !== undefined; }, "canGuard": function() { return this.get("UnitAI/CanGuard") === "true"; }, "canGarrison": function() { return "Garrisonable" in this._template; }, }); // defines an entity, with a super Template. // also redefines several of the template functions where the only change is applying aura and tech modifications. m.Entity = m.Class({ "_super": m.Template, "_init": function(sharedAI, entity) { this._super.call(this, sharedAI, entity.template, sharedAI.GetTemplate(entity.template)); this._entity = entity; this._ai = sharedAI; // save a reference to the template tech modifications if (!sharedAI._templatesModifications[this._templateName]) sharedAI._templatesModifications[this._templateName] = {}; this._templateModif = sharedAI._templatesModifications[this._templateName]; // save a reference to the entity tech/aura modifications if (!sharedAI._entitiesModifications.has(entity.id)) sharedAI._entitiesModifications.set(entity.id, new Map()); this._entityModif = sharedAI._entitiesModifications.get(entity.id); }, "toString": function() { return "[Entity " + this.id() + " " + this.templateName() + "]"; }, "id": function() { return this._entity.id; }, /** * Returns extra data that the AI scripts have associated with this entity, * for arbitrary local annotations. * (This data should not be shared with any other AI scripts.) */ "getMetadata": function(player, key) { return this._ai.getMetadata(player, this, key); }, /** * Sets extra data to be associated with this entity. */ "setMetadata": function(player, key, value) { this._ai.setMetadata(player, this, key, value); }, "deleteAllMetadata": function(player) { delete this._ai._entityMetadata[player][this.id()]; }, "deleteMetadata": function(player, key) { this._ai.deleteMetadata(player, this, key); }, "position": function() { return this._entity.position; }, "angle": function() { return this._entity.angle; }, "isIdle": function() { if (typeof this._entity.idle === "undefined") return undefined; return this._entity.idle; }, "getStance": function() { return this._entity.stance !== undefined ? this._entity.stance : undefined; }, "unitAIState": function() { return this._entity.unitAIState !== undefined ? this._entity.unitAIState : undefined; }, "unitAIOrderData": function() { return this._entity.unitAIOrderData !== undefined ? this._entity.unitAIOrderData : undefined; }, "hitpoints": function() { return this._entity.hitpoints !== undefined ? this._entity.hitpoints : undefined; }, "isHurt": function() { return this.hitpoints() < this.maxHitpoints(); }, "healthLevel": function() { return this.hitpoints() / this.maxHitpoints(); }, "needsHeal": function() { return this.isHurt() && this.isHealable(); }, "needsRepair": function() { return this.isHurt() && this.isRepairable(); }, "decaying": function() { return this._entity.decaying !== undefined ? this._entity.decaying : undefined; }, "capturePoints": function() {return this._entity.capturePoints !== undefined ? this._entity.capturePoints : undefined; }, "isInvulnerable": function() { return this._entity.invulnerability || false; }, "isSharedDropsite": function() { return this._entity.sharedDropsite === true; }, /** * Returns the current training queue state, of the form * [ { "id": 0, "template": "...", "count": 1, "progress": 0.5, "metadata": ... }, ... ] */ "trainingQueue": function() { let queue = this._entity.trainingQueue; return queue; }, "trainingQueueTime": function() { let queue = this._entity.trainingQueue; if (!queue) return undefined; let time = 0; for (let item of queue) time += item.timeRemaining; return time/1000; }, "foundationProgress": function() { if (this._entity.foundationProgress === undefined) return undefined; return this._entity.foundationProgress; }, "getBuilders": function() { if (this._entity.foundationProgress === undefined) return undefined; if (this._entity.foundationBuilders === undefined) return []; return this._entity.foundationBuilders; }, "getBuildersNb": function() { if (this._entity.foundationProgress === undefined) return undefined; if (this._entity.foundationBuilders === undefined) return 0; return this._entity.foundationBuilders.length; }, "owner": function() { return this._entity.owner; }, "isOwn": function(player) { if (typeof this._entity.owner === "undefined") return false; return this._entity.owner === player; }, "resourceSupplyAmount": function() { if (this._entity.resourceSupplyAmount === undefined) return undefined; return this._entity.resourceSupplyAmount; }, "resourceSupplyNumGatherers": function() { if (this._entity.resourceSupplyNumGatherers !== undefined) return this._entity.resourceSupplyNumGatherers; return undefined; }, "isFull": function() { if (this._entity.resourceSupplyNumGatherers !== undefined) return this.maxGatherers() === this._entity.resourceSupplyNumGatherers; return undefined; }, "resourceCarrying": function() { if (this._entity.resourceCarrying === undefined) return undefined; return this._entity.resourceCarrying; }, "currentGatherRate": function() { // returns the gather rate for the current target if applicable. if (!this.get("ResourceGatherer")) return undefined; if (this.unitAIOrderData().length && (this.unitAIState().split(".")[1] == "GATHER" || this.unitAIState().split(".")[1] == "RETURNRESOURCE")) { let res; // this is an abuse of "_ai" but it works. if (this.unitAIState().split(".")[1] == "GATHER" && this.unitAIOrderData()[0].target !== undefined) res = this._ai._entities.get(this.unitAIOrderData()[0].target); else if (this.unitAIOrderData()[1] !== undefined && this.unitAIOrderData()[1].target !== undefined) res = this._ai._entities.get(this.unitAIOrderData()[1].target); if (!res) return 0; let type = res.resourceSupplyType(); if (!type) return 0; if (type.generic == "treasure") return 1000; let tstring = type.generic + "." + type.specific; let rate = +this.get("ResourceGatherer/BaseSpeed"); rate *= +this.get("ResourceGatherer/Rates/" +tstring); if (rate) return rate; return 0; } return undefined; }, "garrisoned": function() { return this._entity.garrisoned; }, "canGarrisonInside": function() { return this._entity.garrisoned.length < this.garrisonMax(); }, /** * returns true if the entity can attack (including capture) the given class. */ "canAttackClass": function(aClass) { if (!this.get("Attack")) return false; for (let type in this.get("Attack")) { if (type == "Slaughter") continue; let restrictedClasses = this.get("Attack/" + type + "/RestrictedClasses/_string"); if (!restrictedClasses || !MatchesClassList([aClass], restrictedClasses)) return true; } return false; }, "move": function(x, z, queued = false) { Engine.PostCommand(PlayerID, { "type": "walk", "entities": [this.id()], "x": x, "z": z, "queued": queued }); return this; }, "moveToRange": function(x, z, min, max, queued = false) { Engine.PostCommand(PlayerID, { "type": "walk-to-range", "entities": [this.id()], "x": x, "z": z, "min": min, "max": max, "queued": queued }); return this; }, "attackMove": function(x, z, targetClasses, allowCapture = true, queued = false) { Engine.PostCommand(PlayerID, { "type": "attack-walk", "entities": [this.id()], "x": x, "z": z, "targetClasses": targetClasses, "allowCapture": allowCapture, "queued": queued }); return this; }, // violent, aggressive, defensive, passive, standground "setStance": function(stance, queued = false) { if (this.getStance() === undefined) return undefined; Engine.PostCommand(PlayerID, { "type": "stance", "entities": [this.id()], "name": stance, "queued": queued }); return this; }, "stopMoving": function() { Engine.PostCommand(PlayerID, { "type": "stop", "entities": [this.id()], "queued": false }); }, "unload": function(id) { if (!this.get("GarrisonHolder")) return undefined; Engine.PostCommand(PlayerID, { "type": "unload", "garrisonHolder": this.id(), "entities": [id] }); return this; }, // Unloads all owned units, don't unload allies "unloadAll": function() { if (!this.get("GarrisonHolder")) return undefined; Engine.PostCommand(PlayerID, { "type": "unload-all-by-owner", "garrisonHolders": [this.id()] }); return this; }, "garrison": function(target, queued = false) { Engine.PostCommand(PlayerID, { "type": "garrison", "entities": [this.id()], "target": target.id(), "queued": queued }); return this; }, "attack": function(unitId, allowCapture = true, queued = false) { Engine.PostCommand(PlayerID, { "type": "attack", "entities": [this.id()], "target": unitId, "allowCapture": allowCapture, "queued": queued }); return this; }, // moveApart from a point in the opposite direction with a distance dist "moveApart": function(point, dist) { if (this.position() !== undefined) { let direction = [this.position()[0] - point[0], this.position()[1] - point[1]]; let norm = m.VectorDistance(point, this.position()); if (norm === 0) direction = [1, 0]; else { direction[0] /= norm; direction[1] /= norm; } Engine.PostCommand(PlayerID, { "type": "walk", "entities": [this.id()], "x": this.position()[0] + direction[0]*dist, "z": this.position()[1] + direction[1]*dist, "queued": false }); } return this; }, // Flees from a unit in the opposite direction. "flee": function(unitToFleeFrom) { if (this.position() !== undefined && unitToFleeFrom.position() !== undefined) { let FleeDirection = [this.position()[0] - unitToFleeFrom.position()[0], this.position()[1] - unitToFleeFrom.position()[1]]; let dist = m.VectorDistance(unitToFleeFrom.position(), this.position()); FleeDirection[0] = 40 * FleeDirection[0]/dist; FleeDirection[1] = 40 * FleeDirection[1]/dist; Engine.PostCommand(PlayerID, { "type": "walk", "entities": [this.id()], "x": this.position()[0] + FleeDirection[0], "z": this.position()[1] + FleeDirection[1], "queued": false }); } return this; }, "gather": function(target, queued = false) { Engine.PostCommand(PlayerID, { "type": "gather", "entities": [this.id()], "target": target.id(), "queued": queued }); return this; }, "repair": function(target, autocontinue = false, queued = false) { Engine.PostCommand(PlayerID, { "type": "repair", "entities": [this.id()], "target": target.id(), "autocontinue": autocontinue, "queued": queued }); return this; }, "returnResources": function(target, queued = false) { Engine.PostCommand(PlayerID, { "type": "returnresource", "entities": [this.id()], "target": target.id(), "queued": queued }); return this; }, "destroy": function() { Engine.PostCommand(PlayerID, { "type": "delete-entities", "entities": [this.id()] }); return this; }, "barter": function(buyType, sellType, amount) { Engine.PostCommand(PlayerID, { "type": "barter", "sell": sellType, "buy": buyType, "amount": amount }); return this; }, "tradeRoute": function(target, source) { Engine.PostCommand(PlayerID, { "type": "setup-trade-route", "entities": [this.id()], "target": target.id(), "source": source.id(), "route": undefined, "queued": false }); return this; }, "setRallyPoint": function(target, command) { let data = { "command": command, "target": target.id() }; Engine.PostCommand(PlayerID, { "type": "set-rallypoint", "entities": [this.id()], "x": target.position()[0], "z": target.position()[1], "data": data }); return this; }, "unsetRallyPoint": function() { Engine.PostCommand(PlayerID, { "type": "unset-rallypoint", "entities": [this.id()] }); return this; }, "train": function(civ, type, count, metadata, promotedTypes) { let trainable = this.trainableEntities(civ); if (!trainable) { error("Called train("+type+", "+count+") on non-training entity "+this); return this; } if (trainable.indexOf(type) == -1) { error("Called train("+type+", "+count+") on entity "+this+" which can't train that"); return this; } Engine.PostCommand(PlayerID, { "type": "train", "entities": [this.id()], "template": type, "count": count, "metadata": metadata, "promoted": promotedTypes }); return this; }, "construct": function(template, x, z, angle, metadata) { // TODO: verify this unit can construct this, just for internal // sanity-checking and error reporting Engine.PostCommand(PlayerID, { "type": "construct", "entities": [this.id()], "template": template, "x": x, "z": z, "angle": angle, "autorepair": false, "autocontinue": false, "queued": false, "metadata": metadata // can be undefined }); return this; }, "research": function(template) { Engine.PostCommand(PlayerID, { "type": "research", "entity": this.id(), "template": template }); return this; }, "stopProduction": function(id) { Engine.PostCommand(PlayerID, { "type": "stop-production", "entity": this.id(), "id": id }); return this; }, "stopAllProduction": function(percentToStopAt) { let queue = this._entity.trainingQueue; if (!queue) return true; // no queue, so technically we stopped all production. for (let item of queue) if (item.progress < percentToStopAt) Engine.PostCommand(PlayerID, { "type": "stop-production", "entity": this.id(), "id": item.id }); return this; }, "guard": function(target, queued = false) { Engine.PostCommand(PlayerID, { "type": "guard", "entities": [this.id()], "target": target.id(), "queued": queued }); return this; }, "removeGuard": function() { Engine.PostCommand(PlayerID, { "type": "remove-guard", "entities": [this.id()] }); return this; } }); return m; }(API3); Index: ps/trunk/binaries/data/mods/public/simulation/components/AIProxy.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/AIProxy.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/AIProxy.js (revision 22754) @@ -1,393 +1,393 @@ function AIProxy() {} AIProxy.prototype.Schema = ""; /** * AIProxy passes its entity's state data to AI scripts. * * Efficiency is critical: there can be many thousands of entities, * and the data returned by this component is serialized and copied to * the AI thread every turn, so it can be quite expensive. * * We omit all data that can be derived statically from the template XML * files - the AI scripts can parse the templates themselves. * This violates the component interface abstraction and is potentially * fragile if the template formats change (since both the component code * and the AI will have to be updated in sync), but it's not *that* bad * really and it helps performance significantly. * * We also add an optimisation to avoid copying non-changing values. * The first call to GetRepresentation calls GetFullRepresentation, * which constructs the complete entity state representation. * After that, we simply listen to events from the rest of the gameplay code, * and store the changed data in this.changes. * Properties in this.changes will override those previously returned * from GetRepresentation; if a property isn't overridden then the AI scripts * will keep its old value. * * The event handlers should set this.changes.whatever to exactly the * same as GetFullRepresentation would set. */ AIProxy.prototype.Init = function() { this.changes = null; this.needsFullGet = true; this.cmpAIInterface = Engine.QueryInterface(SYSTEM_ENTITY, IID_AIInterface); }; AIProxy.prototype.Serialize = null; // we have no dynamic state to save AIProxy.prototype.Deserialize = function() { this.Init(); }; AIProxy.prototype.GetRepresentation = function() { // Return the full representation the first time we're called let ret; if (this.needsFullGet) ret = this.GetFullRepresentation(); else ret = this.changes; // Initialise changes to null instead of {}, to avoid memory allocations in the // common case where there will be no changes; event handlers should each reset // it to {} if needed this.changes = null; return ret; }; AIProxy.prototype.NotifyChange = function() { if (this.needsFullGet) { // not yet notified, be sure that the owner is set before doing so // as the Create event is sent only on first ownership changed let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || cmpOwnership.GetOwner() < 0) return false; } if (!this.changes) { this.changes = {}; this.cmpAIInterface.ChangedEntity(this.entity); } return true; }; // AI representation-updating event handlers: AIProxy.prototype.OnPositionChanged = function(msg) { if (!this.NotifyChange()) return; if (msg.inWorld) { this.changes.position = [msg.x, msg.z]; this.changes.angle = msg.a; } else { this.changes.position = undefined; this.changes.angle = undefined; } }; AIProxy.prototype.OnHealthChanged = function(msg) { if (!this.NotifyChange()) return; this.changes.hitpoints = msg.to; }; AIProxy.prototype.OnCapturePointsChanged = function(msg) { if (!this.NotifyChange()) return; this.changes.capturePoints = msg.capturePoints; }; AIProxy.prototype.OnInvulnerabilityChanged = function(msg) { if (!this.NotifyChange()) return; this.changes.invulnerability = msg.invulnerability; }; AIProxy.prototype.OnUnitIdleChanged = function(msg) { if (!this.NotifyChange()) return; this.changes.idle = msg.idle; }; AIProxy.prototype.OnUnitStanceChanged = function(msg) { if (!this.NotifyChange()) return; this.changes.stance = msg.to; }; AIProxy.prototype.OnUnitAIStateChanged = function(msg) { if (!this.NotifyChange()) return; this.changes.unitAIState = msg.to; }; AIProxy.prototype.OnUnitAIOrderDataChanged = function(msg) { if (!this.NotifyChange()) return; this.changes.unitAIOrderData = msg.to; }; AIProxy.prototype.OnProductionQueueChanged = function(msg) { if (!this.NotifyChange()) return; let cmpProductionQueue = Engine.QueryInterface(this.entity, IID_ProductionQueue); this.changes.trainingQueue = cmpProductionQueue.GetQueue(); }; AIProxy.prototype.OnGarrisonedUnitsChanged = function(msg) { if (!this.NotifyChange()) return; let cmpGarrisonHolder = Engine.QueryInterface(this.entity, IID_GarrisonHolder); this.changes.garrisoned = cmpGarrisonHolder.GetEntities(); // Send a message telling a unit garrisoned or ungarrisoned. // I won't check if the unit is still alive so it'll be up to the AI. for (let ent of msg.added) this.cmpAIInterface.PushEvent("Garrison", { "entity": ent, "holder": this.entity }); for (let ent of msg.removed) this.cmpAIInterface.PushEvent("UnGarrison", { "entity": ent, "holder": this.entity }); }; AIProxy.prototype.OnResourceSupplyChanged = function(msg) { if (!this.NotifyChange()) return; this.changes.resourceSupplyAmount = msg.to; }; AIProxy.prototype.OnResourceSupplyNumGatherersChanged = function(msg) { if (!this.NotifyChange()) return; this.changes.resourceSupplyNumGatherers = msg.to; }; AIProxy.prototype.OnResourceCarryingChanged = function(msg) { if (!this.NotifyChange()) return; this.changes.resourceCarrying = msg.to; }; AIProxy.prototype.OnFoundationProgressChanged = function(msg) { if (!this.NotifyChange()) return; this.changes.foundationProgress = msg.to; }; AIProxy.prototype.OnFoundationBuildersChanged = function(msg) { if (!this.NotifyChange()) return; this.changes.foundationBuilders = msg.to; }; AIProxy.prototype.OnDropsiteSharingChanged = function(msg) { if (!this.NotifyChange()) return; this.changes.sharedDropsite = msg.shared; }; AIProxy.prototype.OnTerritoryDecayChanged = function(msg) { if (!this.NotifyChange()) return; this.changes.decaying = msg.to; this.cmpAIInterface.PushEvent("TerritoryDecayChanged", msg); }; // TODO: event handlers for all the other things AIProxy.prototype.GetFullRepresentation = function() { this.needsFullGet = false; let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); let ret = { // These properties are constant and won't need to be updated "id": this.entity, "template": cmpTemplateManager.GetCurrentTemplateName(this.entity) }; let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (cmpPosition) { // Updated by OnPositionChanged if (cmpPosition.IsInWorld()) { let pos = cmpPosition.GetPosition2D(); ret.position = [pos.x, pos.y]; ret.angle = cmpPosition.GetRotation().y; } else { ret.position = undefined; ret.angle = undefined; } } let cmpHealth = Engine.QueryInterface(this.entity, IID_Health); if (cmpHealth) { // Updated by OnHealthChanged ret.hitpoints = cmpHealth.GetHitpoints(); } - let cmpDamageReceiver = Engine.QueryInterface(this.entity, IID_DamageReceiver); - if (cmpDamageReceiver) - ret.invulnerability = cmpDamageReceiver.IsInvulnerable(); + let cmpResistance = Engine.QueryInterface(this.entity, IID_Resistance); + if (cmpResistance) + ret.invulnerability = cmpResistance.IsInvulnerable(); let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (cmpOwnership) { // Updated by OnOwnershipChanged ret.owner = cmpOwnership.GetOwner(); } let cmpUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI); if (cmpUnitAI) { // Updated by OnUnitIdleChanged ret.idle = cmpUnitAI.IsIdle(); // Updated by OnUnitStanceChanged ret.stance = cmpUnitAI.GetStanceName(); // Updated by OnUnitAIStateChanged ret.unitAIState = cmpUnitAI.GetCurrentState(); // Updated by OnUnitAIOrderDataChanged ret.unitAIOrderData = cmpUnitAI.GetOrderData(); } let cmpProductionQueue = Engine.QueryInterface(this.entity, IID_ProductionQueue); if (cmpProductionQueue) { // Updated by OnProductionQueueChanged ret.trainingQueue = cmpProductionQueue.GetQueue(); } let cmpFoundation = Engine.QueryInterface(this.entity, IID_Foundation); if (cmpFoundation) { // Updated by OnFoundationProgressChanged ret.foundationProgress = cmpFoundation.GetBuildPercentage(); } let cmpResourceSupply = Engine.QueryInterface(this.entity, IID_ResourceSupply); if (cmpResourceSupply) { // Updated by OnResourceSupplyChanged ret.resourceSupplyAmount = cmpResourceSupply.GetCurrentAmount(); ret.resourceSupplyNumGatherers = cmpResourceSupply.GetNumGatherers(); } let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (cmpResourceGatherer) { // Updated by OnResourceCarryingChanged ret.resourceCarrying = cmpResourceGatherer.GetCarryingStatus(); } let cmpResourceDropsite = Engine.QueryInterface(this.entity, IID_ResourceDropsite); if (cmpResourceDropsite) { // Updated by OnDropsiteSharingChanged ret.sharedDropsite = cmpResourceDropsite.IsShared(); } let cmpGarrisonHolder = Engine.QueryInterface(this.entity, IID_GarrisonHolder); if (cmpGarrisonHolder) { // Updated by OnGarrisonedUnitsChanged ret.garrisoned = cmpGarrisonHolder.GetEntities(); } let cmpTerritoryDecay = Engine.QueryInterface(this.entity, IID_TerritoryDecay); if (cmpTerritoryDecay) ret.decaying = cmpTerritoryDecay.IsDecaying(); let cmpCapturable = Engine.QueryInterface(this.entity, IID_Capturable); if (cmpCapturable) ret.capturePoints = cmpCapturable.GetCapturePoints(); return ret; }; // AI event handlers: // (These are passed directly as events to the AI scripts, rather than updating // our proxy representation.) // (This shouldn't include extremely high-frequency events, like PositionChanged, // because that would be very expensive and AI will rarely care about all those // events.) // special case: this changes the state and sends an event. AIProxy.prototype.OnOwnershipChanged = function(msg) { this.NotifyChange(); if (msg.from == INVALID_PLAYER) { this.cmpAIInterface.PushEvent("Create", { "entity": msg.entity }); return; } if (msg.to == INVALID_PLAYER) { this.cmpAIInterface.PushEvent("Destroy", { "entity": msg.entity }); return; } this.changes.owner = msg.to; this.cmpAIInterface.PushEvent("OwnershipChanged", msg); }; AIProxy.prototype.OnAttacked = function(msg) { this.cmpAIInterface.PushEvent("Attacked", msg); }; AIProxy.prototype.OnConstructionFinished = function(msg) { this.cmpAIInterface.PushEvent("ConstructionFinished", msg); }; AIProxy.prototype.OnTrainingStarted = function(msg) { this.cmpAIInterface.PushEvent("TrainingStarted", msg); }; AIProxy.prototype.OnTrainingFinished = function(msg) { this.cmpAIInterface.PushEvent("TrainingFinished", msg); }; AIProxy.prototype.OnAIMetadata = function(msg) { this.cmpAIInterface.PushEvent("AIMetadata", msg); }; Engine.RegisterComponentType(IID_AIProxy, "AIProxy", AIProxy); Index: ps/trunk/binaries/data/mods/public/simulation/components/Armour.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/Armour.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/Armour.js (revision 22754) @@ -1,86 +1,73 @@ function Armour() {} +Armour.prototype.DamageResistanceSchema = "" + + "" + + "" + + "" + + "Foundation" + + "" + + "" + + "" + + ""; + Armour.prototype.Schema = "Controls the damage resistance of the unit." + "" + "10.0" + "0.0" + "5.0" + "" + - BuildDamageTypesSchema("damage protection") + + Armour.prototype.DamageResistanceSchema + "" + "" + - "" + - BuildDamageTypesSchema("damage protection") + - "" + + Armour.prototype.DamageResistanceSchema + "" + ""; Armour.prototype.Init = function() { this.invulnerable = false; }; Armour.prototype.IsInvulnerable = function() { return this.invulnerable; }; Armour.prototype.SetInvulnerability = function(invulnerability) { this.invulnerable = invulnerability; Engine.PostMessage(this.entity, MT_InvulnerabilityChanged, { "entity": this.entity, "invulnerability": invulnerability }); }; -/** - * Take damage according to the entity's armor. - * @param {Object} strengths - { "hack": number, "pierce": number, "crush": number } or something like that. - * @param {number} multiplier - the damage multiplier. - * Returns object of the form { "killed": false, "change": -12 }. - */ -Armour.prototype.TakeDamage = function(strengths, multiplier = 1) -{ - if (this.invulnerable) - return { "killed": false, "change": 0 }; - - // Adjust damage values based on armour; exponential armour: damage = attack * 0.9^armour - var armourStrengths = this.GetArmourStrengths(); - - // Total is sum of individual damages - // Don't bother rounding, since HP is no longer integral. - var total = 0; - for (let type in strengths) - total += strengths[type] * multiplier * Math.pow(0.9, armourStrengths[type] || 0); - - // Reduce health - var cmpHealth = Engine.QueryInterface(this.entity, IID_Health); - return cmpHealth.Reduce(total); -}; - -Armour.prototype.GetArmourStrengths = function() +Armour.prototype.GetArmourStrengths = function(effectType) { // Work out the armour values with technology effects. let applyMods = (type, foundation) => { let strength; if (foundation) { strength = +this.template.Foundation[type]; type = "Foundation/" + type; } else strength = +this.template[type]; - return ApplyValueModificationsToEntity("Armour/" + type, strength, this.entity); + return ApplyValueModificationsToEntity("Armour/" + effectType + "/" + type, strength, this.entity); }; let foundation = Engine.QueryInterface(this.entity, IID_Foundation) && this.template.Foundation; let ret = {}; + + if (effectType != "Damage") + return ret; + for (let damageType in this.template) if (damageType != "Foundation") ret[damageType] = applyMods(damageType, foundation); return ret; }; -Engine.RegisterComponentType(IID_DamageReceiver, "Armour", Armour); +Engine.RegisterComponentType(IID_Resistance, "Armour", Armour); Index: ps/trunk/binaries/data/mods/public/simulation/components/Attack.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/Attack.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/Attack.js (revision 22754) @@ -1,725 +1,611 @@ function Attack() {} var g_AttackTypes = ["Melee", "Ranged", "Capture"]; -Attack.prototype.statusEffectsSchema = - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - ""; - -Attack.prototype.bonusesSchema = - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - ""; - Attack.prototype.preferredClassesSchema = "" + "" + "" + "tokens" + "" + "" + "" + ""; Attack.prototype.restrictedClassesSchema = "" + "" + "" + "tokens" + "" + "" + "" + ""; Attack.prototype.Schema = "Controls the attack abilities and strengths of the unit." + "" + "" + "" + "10.0" + "0.0" + "5.0" + "" + "4.0" + "1000" + "" + "" + "pers" + "Infantry" + "1.5" + "" + "" + "Cavalry Melee" + "1.5" + "" + "" + "Champion" + "Cavalry Infantry" + "" + "" + "" + "0.0" + "10.0" + "0.0" + "" + "44.0" + "20.0" + "15.0" + "800" + "1600" + "1000" + "" + "" + "Cavalry" + "2" + "" + "" + "" + "50.0" + "2.5" + "props/units/weapons/rock_flaming.xml" + "props/units/weapons/rock_explosion.xml" + "0.1" + "" + "Champion" + "" + "Circular" + "20" + "false" + "" + "0.0" + "10.0" + "0.0" + "" + "" + "" + "" + "" + "1000.0" + "0.0" + "0.0" + "" + "4.0" + "" + "" + "" + "" + "" + - "" + - BuildDamageTypesSchema("damage strength") + - "" + + Attacking.BuildAttackEffectsSchema() + "" + "" + "" + "" + "" + // TODO: it shouldn't be stretched "" + "" + - Attack.prototype.bonusesSchema + Attack.prototype.preferredClassesSchema + Attack.prototype.restrictedClassesSchema + "" + "" + "" + "" + "" + "" + - "" + - BuildDamageTypesSchema("damage strength") + - "" + + Attacking.BuildAttackEffectsSchema() + "" + "" + ""+ "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + - "" + - BuildDamageTypesSchema("damage strength") + - "" + - Attack.prototype.bonusesSchema + + Attacking.BuildAttackEffectsSchema() + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + - Attack.prototype.statusEffectsSchema + - Attack.prototype.bonusesSchema + Attack.prototype.preferredClassesSchema + Attack.prototype.restrictedClassesSchema + "" + "" + "" + "" + "" + "" + - "" + + Attacking.BuildAttackEffectsSchema() + "" + "" + // TODO: it shouldn't be stretched "" + "" + - Attack.prototype.bonusesSchema + Attack.prototype.preferredClassesSchema + Attack.prototype.restrictedClassesSchema + "" + "" + "" + "" + "" + "" + - "" + - BuildDamageTypesSchema("damage strength") + - "" + + Attacking.BuildAttackEffectsSchema() + "" + // TODO: how do these work? - Attack.prototype.bonusesSchema + Attack.prototype.preferredClassesSchema + Attack.prototype.restrictedClassesSchema + "" + "" + ""; Attack.prototype.Init = function() { }; Attack.prototype.Serialize = null; // we have no dynamic state to save Attack.prototype.GetAttackTypes = function(wantedTypes) { let types = g_AttackTypes.filter(type => !!this.template[type]); if (!wantedTypes) return types; let wantedTypesReal = wantedTypes.filter(wtype => wtype.indexOf("!") != 0); return types.filter(type => wantedTypes.indexOf("!" + type) == -1 && (!wantedTypesReal || !wantedTypesReal.length || wantedTypesReal.indexOf(type) != -1)); }; Attack.prototype.GetPreferredClasses = function(type) { if (this.template[type] && this.template[type].PreferredClasses && this.template[type].PreferredClasses._string) return this.template[type].PreferredClasses._string.split(/\s+/); return []; }; Attack.prototype.GetRestrictedClasses = function(type) { if (this.template[type] && this.template[type].RestrictedClasses && this.template[type].RestrictedClasses._string) return this.template[type].RestrictedClasses._string.split(/\s+/); return []; }; Attack.prototype.CanAttack = function(target, wantedTypes) { let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) return true; let cmpThisPosition = Engine.QueryInterface(this.entity, IID_Position); let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); if (!cmpThisPosition || !cmpTargetPosition || !cmpThisPosition.IsInWorld() || !cmpTargetPosition.IsInWorld()) return false; let cmpIdentity = QueryMiragedInterface(target, IID_Identity); if (!cmpIdentity) return false; let cmpHealth = QueryMiragedInterface(target, IID_Health); let targetClasses = cmpIdentity.GetClassesList(); if (targetClasses.indexOf("Domestic") != -1 && this.template.Slaughter && cmpHealth && cmpHealth.GetHitpoints() && (!wantedTypes || !wantedTypes.filter(wType => wType.indexOf("!") != 0).length)) return true; let cmpEntityPlayer = QueryOwnerInterface(this.entity); let cmpTargetPlayer = QueryOwnerInterface(target); if (!cmpTargetPlayer || !cmpEntityPlayer) return false; let types = this.GetAttackTypes(wantedTypes); let entityOwner = cmpEntityPlayer.GetPlayerID(); let targetOwner = cmpTargetPlayer.GetPlayerID(); let cmpCapturable = QueryMiragedInterface(target, IID_Capturable); // Check if the relative height difference is larger than the attack range // If the relative height is bigger, it means they will never be able to // reach each other, no matter how close they come. let heightDiff = Math.abs(cmpThisPosition.GetHeightOffset() - cmpTargetPosition.GetHeightOffset()); for (let type of types) { if (type != "Capture" && (!cmpEntityPlayer.IsEnemy(targetOwner) || !cmpHealth || !cmpHealth.GetHitpoints())) continue; if (type == "Capture" && (!cmpCapturable || !cmpCapturable.CanCapture(entityOwner))) continue; if (heightDiff > this.GetRange(type).max) continue; let restrictedClasses = this.GetRestrictedClasses(type); if (!restrictedClasses.length) return true; if (!MatchesClassList(targetClasses, restrictedClasses)) return true; } return false; }; /** * Returns null if we have no preference or the lowest index of a preferred class. */ Attack.prototype.GetPreference = function(target) { let cmpIdentity = Engine.QueryInterface(target, IID_Identity); if (!cmpIdentity) return undefined; let targetClasses = cmpIdentity.GetClassesList(); let minPref = null; for (let type of this.GetAttackTypes()) { let preferredClasses = this.GetPreferredClasses(type); for (let targetClass of targetClasses) { let pref = preferredClasses.indexOf(targetClass); if (pref === 0) return pref; if (pref != -1 && (minPref === null || minPref > pref)) minPref = pref; } } return minPref; }; /** * Get the full range of attack using all available attack types. */ Attack.prototype.GetFullAttackRange = function() { let ret = { "min": Infinity, "max": 0 }; for (let type of this.GetAttackTypes()) { let range = this.GetRange(type); ret.min = Math.min(ret.min, range.min); ret.max = Math.max(ret.max, range.max); } return ret; }; +Attack.prototype.GetAttackEffectsData = function(type, splash) +{ + let tp = this.template[type]; + if (splash) + tp = tp.Splash; + return Attacking.GetAttackEffectsData("Attack/" + type + splash ? "/Splash" : "", tp, this.entity); +}; + Attack.prototype.GetBestAttackAgainst = function(target, allowCapture) { let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) { // TODO: Formation against formation needs review let types = this.GetAttackTypes(); return g_AttackTypes.find(attack => types.indexOf(attack) != -1); } let cmpIdentity = Engine.QueryInterface(target, IID_Identity); if (!cmpIdentity) return undefined; let targetClasses = cmpIdentity.GetClassesList(); let isTargetClass = className => targetClasses.indexOf(className) != -1; // Always slaughter domestic animals instead of using a normal attack if (isTargetClass("Domestic") && this.template.Slaughter) return "Slaughter"; let types = this.GetAttackTypes().filter(type => this.CanAttack(target, [type])); // Check whether the target is capturable and prefer that when it is allowed. let captureIndex = types.indexOf("Capture"); if (captureIndex != -1) { if (allowCapture) return "Capture"; types.splice(captureIndex, 1); } let isPreferred = className => this.GetPreferredClasses(className).some(isTargetClass); return types.sort((a, b) => (types.indexOf(a) + (isPreferred(a) ? types.length : 0)) - (types.indexOf(b) + (isPreferred(b) ? types.length : 0))).pop(); }; Attack.prototype.CompareEntitiesByPreference = function(a, b) { let aPreference = this.GetPreference(a); let bPreference = this.GetPreference(b); if (aPreference === null && bPreference === null) return 0; if (aPreference === null) return 1; if (bPreference === null) return -1; return aPreference - bPreference; }; Attack.prototype.GetTimers = function(type) { let prepare = +(this.template[type].PrepareTime || 0); prepare = ApplyValueModificationsToEntity("Attack/" + type + "/PrepareTime", prepare, this.entity); let repeat = +(this.template[type].RepeatTime || 1000); repeat = ApplyValueModificationsToEntity("Attack/" + type + "/RepeatTime", repeat, this.entity); return { "prepare": prepare, "repeat": repeat }; }; -Attack.prototype.GetAttackStrengths = function(type) -{ - // Work out the attack values with technology effects - let template = this.template[type]; - let splash = ""; - if (!template) - { - template = this.template[type.split(".")[0]].Splash; - splash = "/Splash"; - } - - let applyMods = damageType => - ApplyValueModificationsToEntity("Attack/" + type + splash + "/Damage/" + damageType, +(template.Damage[damageType] || 0), this.entity); - - if (type == "Capture") - return { "value": ApplyValueModificationsToEntity("Attack/Capture/Value", +(template.Value || 0), this.entity) }; - - let ret = {}; - for (let damageType in template.Damage) - ret[damageType] = applyMods(damageType); - - return ret; -}; - Attack.prototype.GetSplashDamage = function(type) { if (!this.template[type].Splash) return false; - let splash = {}; - splash.damage = this.GetAttackStrengths(type + ".Splash"); - splash.friendlyFire = this.template[type].Splash.FriendlyFire != "false"; - splash.shape = this.template[type].Splash.Shape; - return splash; + return { + "attackData": this.GetAttackEffectsData(type, true), + "friendlyFire": this.template[type].Splash.FriendlyFire != "false", + "shape": this.template[type].Splash.Shape, + }; }; Attack.prototype.GetRange = function(type) { let max = +this.template[type].MaxRange; max = ApplyValueModificationsToEntity("Attack/" + type + "/MaxRange", max, this.entity); let min = +(this.template[type].MinRange || 0); min = ApplyValueModificationsToEntity("Attack/" + type + "/MinRange", min, this.entity); let elevationBonus = +(this.template[type].ElevationBonus || 0); elevationBonus = ApplyValueModificationsToEntity("Attack/" + type + "/ElevationBonus", elevationBonus, this.entity); return { "max": max, "min": min, "elevationBonus": elevationBonus }; }; -Attack.prototype.GetBonusTemplate = function(type) -{ - let template = this.template[type]; - if (!template) - template = this.template[type.split(".")[0]].Splash; - - return template.Bonuses || null; -}; - /** * Attack the target entity. This should only be called after a successful range check, * and should only be called after GetTimers().repeat msec has passed since the last * call to PerformAttack. */ Attack.prototype.PerformAttack = function(type, target) { let attackerOwner = Engine.QueryInterface(this.entity, IID_Ownership).GetOwner(); - let cmpDamage = Engine.QueryInterface(SYSTEM_ENTITY, IID_Damage); // If this is a ranged attack, then launch a projectile if (type == "Ranged") { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); let turnLength = cmpTimer.GetLatestTurnLength()/1000; // In the future this could be extended: // * Obstacles like trees could reduce the probability of the target being hit // * Obstacles like walls should block projectiles entirely let horizSpeed = +this.template[type].Projectile.Speed; let gravity = +this.template[type].Projectile.Gravity; - //horizSpeed /= 2; gravity /= 2; // slow it down for testing + // horizSpeed /= 2; gravity /= 2; // slow it down for testing let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return; let selfPosition = cmpPosition.GetPosition(); let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) return; let targetPosition = cmpTargetPosition.GetPosition(); let previousTargetPosition = Engine.QueryInterface(target, IID_Position).GetPreviousPosition(); let targetVelocity = Vector3D.sub(targetPosition, previousTargetPosition).div(turnLength); let timeToTarget = this.PredictTimeToTarget(selfPosition, horizSpeed, targetPosition, targetVelocity); let predictedPosition = (timeToTarget !== false) ? Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition) : targetPosition; // Add inaccuracy based on spread. let distanceModifiedSpread = ApplyValueModificationsToEntity("Attack/Ranged/Spread", +this.template[type].Projectile.Spread, this.entity) * predictedPosition.horizDistanceTo(selfPosition) / 100; let randNorm = randomNormal2D(); let offsetX = randNorm[0] * distanceModifiedSpread; let offsetZ = randNorm[1] * distanceModifiedSpread; let realTargetPosition = new Vector3D(predictedPosition.x + offsetX, targetPosition.y, predictedPosition.z + offsetZ); // Recalculate when the missile will hit the target position. let realHorizDistance = realTargetPosition.horizDistanceTo(selfPosition); timeToTarget = realHorizDistance / horizSpeed; let missileDirection = Vector3D.sub(realTargetPosition, selfPosition).div(realHorizDistance); // Launch the graphical projectile. let cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager); let actorName = ""; let impactActorName = ""; let impactAnimationLifetime = 0; actorName = this.template[type].Projectile.ActorName || ""; impactActorName = this.template[type].Projectile.ImpactActorName || ""; impactAnimationLifetime = this.template[type].Projectile.ImpactAnimationLifetime || 0; // TODO: Use unit rotation to implement x/z offsets. let deltaLaunchPoint = new Vector3D(0, this.template[type].Projectile.LaunchPoint["@y"], 0.0); let launchPoint = Vector3D.add(selfPosition, deltaLaunchPoint); - + let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (cmpVisual) { // if the projectile definition is missing from the template // then fallback to the projectile name and launchpoint in the visual actor if (!actorName) actorName = cmpVisual.GetProjectileActor(); let visualActorLaunchPoint = cmpVisual.GetProjectileLaunchPoint(); if (visualActorLaunchPoint.length() > 0) launchPoint = visualActorLaunchPoint; } let id = cmpProjectileManager.LaunchProjectileAtPoint(launchPoint, realTargetPosition, horizSpeed, gravity, actorName, impactActorName, impactAnimationLifetime); let attackImpactSound = ""; let cmpSound = Engine.QueryInterface(this.entity, IID_Sound); if (cmpSound) attackImpactSound = cmpSound.GetSoundGroup("attack_impact_" + type.toLowerCase()); let data = { "type": type, - "attacker": this.entity, + "attackData": this.GetAttackEffectsData(type), "target": target, - "strengths": this.GetAttackStrengths(type), + "attacker": this.entity, + "attackerOwner": attackerOwner, "position": realTargetPosition, "direction": missileDirection, "projectileId": id, - "bonus": this.GetBonusTemplate(type), - "isSplash": false, - "attackerOwner": attackerOwner, - "attackImpactSound": attackImpactSound, - "statusEffects": this.template[type].StatusEffects + "attackImpactSound": attackImpactSound }; if (this.template[type].Splash) - { - data.friendlyFire = this.template[type].Splash.FriendlyFire != "false"; - data.radius = +this.template[type].Splash.Range; - data.shape = this.template[type].Splash.Shape; - data.isSplash = true; - data.splashStrengths = this.GetAttackStrengths(type + ".Splash"); - data.splashBonus = this.GetBonusTemplate(type + ".Splash"); - } - cmpTimer.SetTimeout(SYSTEM_ENTITY, IID_Damage, "MissileHit", timeToTarget * 1000 + +this.template[type].Delay, data); - } - else if (type == "Capture") - { - if (attackerOwner == INVALID_PLAYER) - return; - - let multiplier = GetDamageBonus(this.entity, target, type, this.GetBonusTemplate(type)); - let cmpHealth = Engine.QueryInterface(target, IID_Health); - if (!cmpHealth || cmpHealth.GetHitpoints() == 0) - return; - multiplier *= cmpHealth.GetMaxHitpoints() / (0.1 * cmpHealth.GetMaxHitpoints() + 0.9 * cmpHealth.GetHitpoints()); - - let cmpCapturable = Engine.QueryInterface(target, IID_Capturable); - if (!cmpCapturable || !cmpCapturable.CanCapture(attackerOwner)) - return; - - let strength = this.GetAttackStrengths("Capture").value * multiplier; - if (cmpCapturable.Reduce(strength, attackerOwner) && IsOwnedByEnemyOfPlayer(attackerOwner, target)) - Engine.PostMessage(target, MT_Attacked, { - "attacker": this.entity, - "target": target, - "type": type, - "damage": strength, - "attackerOwner": attackerOwner - }); + data.splash = { + "friendlyFire": this.template[type].Splash.FriendlyFire != "false", + "radius": +this.template[type].Splash.Range, + "shape": this.template[type].Splash.Shape, + "attackData": this.GetAttackEffectsData(type, true), + }; + cmpTimer.SetTimeout(SYSTEM_ENTITY, IID_DelayedDamage, "MissileHit", +this.template[type].Delay + timeToTarget * 1000, data); } else - { - // Melee attack - hurt the target immediately - cmpDamage.CauseDamage({ - "strengths": this.GetAttackStrengths(type), - "target": target, - "attacker": this.entity, - "multiplier": GetDamageBonus(this.entity, target, type, this.GetBonusTemplate(type)), - "type": type, - "attackerOwner": attackerOwner - }); - } + Attacking.HandleAttackEffects(type, this.GetAttackEffectsData(type), target, this.entity, attackerOwner); }; /** * Get the predicted time of collision between a projectile (or a chaser) * and its target, assuming they both move in straight line at a constant speed. * Vertical component of movement is ignored. * @param {Vector3D} selfPosition - the 3D position of the projectile (or chaser). * @param {number} horizSpeed - the horizontal speed of the projectile (or chaser). * @param {Vector3D} targetPosition - the 3D position of the target. * @param {Vector3D} targetVelocity - the 3D velocity vector of the target. * @return {Vector3D|boolean} - the 3D predicted position or false if the collision will not happen. */ Attack.prototype.PredictTimeToTarget = function(selfPosition, horizSpeed, targetPosition, targetVelocity) { let relativePosition = new Vector3D.sub(targetPosition, selfPosition); let a = targetVelocity.x * targetVelocity.x + targetVelocity.z * targetVelocity.z - horizSpeed * horizSpeed; let b = relativePosition.x * targetVelocity.x + relativePosition.z * targetVelocity.z; let c = relativePosition.x * relativePosition.x + relativePosition.z * relativePosition.z; // The predicted time to reach the target is the smallest non negative solution // (when it exists) of the equation a t^2 + 2 b t + c = 0. // Using c>=0, we can straightly compute the right solution. if (c == 0) return 0; let disc = b * b - a * c; if (a < 0 || b < 0 && disc >= 0) return c / (Math.sqrt(disc) - b); return false; }; Attack.prototype.OnValueModification = function(msg) { if (msg.component != "Attack") return; let cmpUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI); if (!cmpUnitAI) return; if (this.GetAttackTypes().some(type => msg.valueNames.indexOf("Attack/" + type + "/MaxRange") != -1)) cmpUnitAI.UpdateRangeQueries(); }; Attack.prototype.GetRangeOverlays = function() { if (!this.template.Ranged || !this.template.Ranged.RangeOverlay) return []; let range = this.GetRange("Ranged"); let rangeOverlays = []; for (let i in range) if ((i == "min" || i == "max") && range[i]) rangeOverlays.push({ "radius": range[i], "texture": this.template.Ranged.RangeOverlay.LineTexture, "textureMask": this.template.Ranged.RangeOverlay.LineTextureMask, "thickness": +this.template.Ranged.RangeOverlay.LineThickness, }); return rangeOverlays; }; Engine.RegisterComponentType(IID_Attack, "Attack", Attack); Index: ps/trunk/binaries/data/mods/public/simulation/components/BuildingAI.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/BuildingAI.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/BuildingAI.js (revision 22754) @@ -1,379 +1,379 @@ //Number of rounds of firing per 2 seconds const roundCount = 10; const attackType = "Ranged"; function BuildingAI() {} BuildingAI.prototype.Schema = "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; BuildingAI.prototype.MAX_PREFERENCE_BONUS = 2; BuildingAI.prototype.Init = function() { this.currentRound = 0; this.archersGarrisoned = 0; this.arrowsLeft = 0; this.targetUnits = []; }; BuildingAI.prototype.OnGarrisonedUnitsChanged = function(msg) { let classes = this.template.GarrisonArrowClasses; for (let ent of msg.added) { let cmpIdentity = Engine.QueryInterface(ent, IID_Identity); if (!cmpIdentity) continue; if (MatchesClassList(cmpIdentity.GetClassesList(), classes)) ++this.archersGarrisoned; } for (let ent of msg.removed) { let cmpIdentity = Engine.QueryInterface(ent, IID_Identity); if (!cmpIdentity) continue; if (MatchesClassList(cmpIdentity.GetClassesList(), classes)) --this.archersGarrisoned; } }; BuildingAI.prototype.OnOwnershipChanged = function(msg) { this.targetUnits = []; this.SetupRangeQuery(); this.SetupGaiaRangeQuery(); }; BuildingAI.prototype.OnDiplomacyChanged = function(msg) { if (!IsOwnedByPlayer(msg.player, this.entity)) return; // Remove maybe now allied/neutral units this.targetUnits = []; this.SetupRangeQuery(); this.SetupGaiaRangeQuery(); }; BuildingAI.prototype.OnDestroy = function() { if (this.timer) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.CancelTimer(this.timer); this.timer = undefined; } // Clean up range queries let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.enemyUnitsQuery) cmpRangeManager.DestroyActiveQuery(this.enemyUnitsQuery); if (this.gaiaUnitsQuery) cmpRangeManager.DestroyActiveQuery(this.gaiaUnitsQuery); }; /** * React on Attack value modifications, as it might influence the range */ BuildingAI.prototype.OnValueModification = function(msg) { if (msg.component != "Attack") return; this.targetUnits = []; this.SetupRangeQuery(); this.SetupGaiaRangeQuery(); }; /** * Setup the Range Query to detect units coming in & out of range */ BuildingAI.prototype.SetupRangeQuery = function() { var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return; var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.enemyUnitsQuery) { cmpRangeManager.DestroyActiveQuery(this.enemyUnitsQuery); this.enemyUnitsQuery = undefined; } var cmpPlayer = QueryOwnerInterface(this.entity); if (!cmpPlayer) return; var enemies = cmpPlayer.GetEnemies(); if (enemies.length && enemies[0] == 0) enemies.shift(); // remove gaia if (!enemies.length) return; var range = cmpAttack.GetRange(attackType); this.enemyUnitsQuery = cmpRangeManager.CreateActiveParabolicQuery( this.entity, range.min, range.max, range.elevationBonus, - enemies, IID_DamageReceiver, cmpRangeManager.GetEntityFlagMask("normal")); + enemies, IID_Resistance, cmpRangeManager.GetEntityFlagMask("normal")); cmpRangeManager.EnableActiveQuery(this.enemyUnitsQuery); }; // Set up a range query for Gaia units within LOS range which can be attacked. // This should be called whenever our ownership changes. BuildingAI.prototype.SetupGaiaRangeQuery = function() { var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return; var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.gaiaUnitsQuery) { cmpRangeManager.DestroyActiveQuery(this.gaiaUnitsQuery); this.gaiaUnitsQuery = undefined; } var cmpPlayer = QueryOwnerInterface(this.entity); if (!cmpPlayer || !cmpPlayer.IsEnemy(0)) return; var range = cmpAttack.GetRange(attackType); // This query is only interested in Gaia entities that can attack. this.gaiaUnitsQuery = cmpRangeManager.CreateActiveParabolicQuery( this.entity, range.min, range.max, range.elevationBonus, [0], IID_Attack, cmpRangeManager.GetEntityFlagMask("normal")); cmpRangeManager.EnableActiveQuery(this.gaiaUnitsQuery); }; /** * Called when units enter or leave range */ BuildingAI.prototype.OnRangeUpdate = function(msg) { var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return; // Target enemy units except non-dangerous animals if (msg.tag == this.gaiaUnitsQuery) { msg.added = msg.added.filter(e => { let cmpUnitAI = Engine.QueryInterface(e, IID_UnitAI); return cmpUnitAI && (!cmpUnitAI.IsAnimal() || cmpUnitAI.IsDangerousAnimal()); }); } else if (msg.tag != this.enemyUnitsQuery) return; // Add new targets for (let entity of msg.added) if (cmpAttack.CanAttack(entity)) this.targetUnits.push(entity); // Remove targets outside of vision-range for (let entity of msg.removed) { let index = this.targetUnits.indexOf(entity); if (index > -1) this.targetUnits.splice(index, 1); } if (this.targetUnits.length) this.StartTimer(); }; BuildingAI.prototype.StartTimer = function() { if (this.timer) return; var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return; var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); var attackTimers = cmpAttack.GetTimers(attackType); this.timer = cmpTimer.SetInterval(this.entity, IID_BuildingAI, "FireArrows", attackTimers.prepare, attackTimers.repeat / roundCount, null); }; BuildingAI.prototype.GetDefaultArrowCount = function() { var arrowCount = +this.template.DefaultArrowCount; return Math.round(ApplyValueModificationsToEntity("BuildingAI/DefaultArrowCount", arrowCount, this.entity)); }; BuildingAI.prototype.GetMaxArrowCount = function() { if (!this.template.MaxArrowCount) return Infinity; let maxArrowCount = +this.template.MaxArrowCount; return Math.round(ApplyValueModificationsToEntity("BuildingAI/MaxArrowCount", maxArrowCount, this.entity)); }; BuildingAI.prototype.GetGarrisonArrowMultiplier = function() { var arrowMult = +this.template.GarrisonArrowMultiplier; return ApplyValueModificationsToEntity("BuildingAI/GarrisonArrowMultiplier", arrowMult, this.entity); }; BuildingAI.prototype.GetGarrisonArrowClasses = function() { var string = this.template.GarrisonArrowClasses; if (string) return string.split(/\s+/); return []; }; /** * Returns the number of arrows which needs to be fired. * DefaultArrowCount + Garrisoned Archers(ie., any unit capable * of shooting arrows from inside buildings) */ BuildingAI.prototype.GetArrowCount = function() { let count = this.GetDefaultArrowCount() + Math.round(this.archersGarrisoned * this.GetGarrisonArrowMultiplier()); return Math.min(count, this.GetMaxArrowCount()); }; BuildingAI.prototype.SetUnitAITarget = function(ent) { this.unitAITarget = ent; if (ent) this.StartTimer(); }; /** * Fire arrows with random temporal distribution on prefered targets. * Called 'roundCount' times every 'RepeatTime' seconds when there are units in the range. */ BuildingAI.prototype.FireArrows = function() { if (!this.targetUnits.length && !this.unitAITarget) { if (!this.timer) return; let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.CancelTimer(this.timer); this.timer = undefined; return; } let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return; if (this.currentRound > roundCount - 1) this.currentRound = 0; if (this.currentRound == 0) this.arrowsLeft = this.GetArrowCount(); let arrowsToFire = 0; if (this.currentRound == roundCount - 1) arrowsToFire = this.arrowsLeft; else arrowsToFire = Math.min( randIntInclusive(0, 2 * this.GetArrowCount() / roundCount), this.arrowsLeft ); if (arrowsToFire <= 0) { ++this.currentRound; return; } // Add targets to a weighted list, to allow preferences let targets = new WeightedList(); let maxPreference = this.MAX_PREFERENCE_BONUS; let addTarget = function(target) { let preference = cmpAttack.GetPreference(target); let weight = 1; if (preference !== null && preference !== undefined) weight += maxPreference / (1 + preference); targets.push(target, weight); }; // Add the UnitAI target separately, as the UnitMotion and RangeManager implementations differ if (this.unitAITarget && this.targetUnits.indexOf(this.unitAITarget) == -1) addTarget(this.unitAITarget); for (let target of this.targetUnits) addTarget(target); for (let i = 0; i < arrowsToFire; ++i) { let selectedIndex = targets.randomIndex(); let selectedTarget = targets.itemAt(selectedIndex); if (selectedTarget && this.CheckTargetVisible(selectedTarget)) { cmpAttack.PerformAttack(attackType, selectedTarget); PlaySound("attack_" + attackType.toLowerCase(), this.entity); continue; } // Could not attack target, retry targets.removeAt(selectedIndex); --i; if (!targets.length()) { this.arrowsLeft += arrowsToFire; break; } } this.arrowsLeft -= arrowsToFire; this.currentRound++; }; /** * Returns true if the target entity is visible through the FoW/SoD. */ BuildingAI.prototype.CheckTargetVisible = function(target) { var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership) return false; // Entities that are hidden and miraged are considered visible var cmpFogging = Engine.QueryInterface(target, IID_Fogging); if (cmpFogging && cmpFogging.IsMiraged(cmpOwnership.GetOwner())) return true; // Either visible directly, or visible in fog let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); return cmpRangeManager.GetLosVisibility(target, cmpOwnership.GetOwner()) != "hidden"; }; Engine.RegisterComponentType(IID_BuildingAI, "BuildingAI", BuildingAI); Index: ps/trunk/binaries/data/mods/public/simulation/components/Capturable.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/Capturable.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/Capturable.js (revision 22754) @@ -1,326 +1,344 @@ function Capturable() {} Capturable.prototype.Schema = "" + "" + "" + "" + "" + "" + "" + "" + ""; Capturable.prototype.Init = function() { // Cache this value this.maxCp = +this.template.CapturePoints; this.cp = []; }; //// Interface functions //// /** * Returns the current capture points array */ Capturable.prototype.GetCapturePoints = function() { return this.cp; }; Capturable.prototype.GetMaxCapturePoints = function() { return this.maxCp; }; Capturable.prototype.GetGarrisonRegenRate = function() { return ApplyValueModificationsToEntity("Capturable/GarrisonRegenRate", +this.template.GarrisonRegenRate, this.entity); }; /** * Set the new capture points, used for cloning entities * The caller should assure that the sum of capture points * matches the max. */ Capturable.prototype.SetCapturePoints = function(capturePointsArray) { this.cp = capturePointsArray; }; +Capturable.prototype.Capture = function(effectData, attacker, attackerOwner, bonusMultiplier) +{ + let cmpHealth = Engine.QueryInterface(this.entity, IID_Health); + + if (attackerOwner == INVALID_PLAYER || !this.CanCapture(attackerOwner) || + !cmpHealth || cmpHealth.GetHitpoints() == 0) + return {}; + + bonusMultiplier *= cmpHealth.GetMaxHitpoints() / (0.1 * cmpHealth.GetMaxHitpoints() + 0.9 * cmpHealth.GetHitpoints()); + + let total = Attacking.GetTotalAttackEffects({ "Capture": effectData }, "Capture") * bonusMultiplier; + + let change = this.Reduce(total, attackerOwner); + // TODO: implement loot + + return { "captureChange": change }; +}; + /** * Reduces the amount of capture points of an entity, * in favour of the player of the source * Returns the number of capture points actually taken */ Capturable.prototype.Reduce = function(amount, playerID) { if (amount <= 0) return 0; var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || cmpOwnership.GetOwner() == INVALID_PLAYER) return 0; var cmpPlayerSource = QueryPlayerIDInterface(playerID); if (!cmpPlayerSource) return 0; // Before changing the value, activate Fogging if necessary to hide changes var cmpFogging = Engine.QueryInterface(this.entity, IID_Fogging); if (cmpFogging) cmpFogging.Activate(); var numberOfEnemies = this.cp.filter((v, i) => v > 0 && cmpPlayerSource.IsEnemy(i)).length; if (numberOfEnemies == 0) return 0; // Distribute the capture points over all enemies. let distributedAmount = amount / numberOfEnemies; let removedAmount = 0; while (distributedAmount > 0.0001) { numberOfEnemies = 0; for (let i in this.cp) { if (!this.cp[i] || !cmpPlayerSource.IsEnemy(i)) continue; if (this.cp[i] > distributedAmount) { removedAmount += distributedAmount; this.cp[i] -= distributedAmount; ++numberOfEnemies; } else { removedAmount += this.cp[i]; this.cp[i] = 0; } } distributedAmount = numberOfEnemies ? (amount - removedAmount) / numberOfEnemies : 0; } // give all cp taken to the player var takenCp = this.maxCp - this.cp.reduce((a, b) => a + b); this.cp[playerID] += takenCp; this.CheckTimer(); this.RegisterCapturePointsChanged(); return takenCp; }; /** * Check if the source can (re)capture points from this building */ Capturable.prototype.CanCapture = function(playerID) { var cmpPlayerSource = QueryPlayerIDInterface(playerID); if (!cmpPlayerSource) warn(playerID + " has no player component defined on its id"); var cp = this.GetCapturePoints(); var sourceEnemyCp = 0; for (let i in this.GetCapturePoints()) if (cmpPlayerSource.IsEnemy(i)) sourceEnemyCp += cp[i]; return sourceEnemyCp > 0; }; //// Private functions //// /** * This has to be called whenever the capture points are changed. * It notifies other components of the change, and switches ownership when needed. */ Capturable.prototype.RegisterCapturePointsChanged = function() { var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership) return; Engine.PostMessage(this.entity, MT_CapturePointsChanged, { "capturePoints": this.cp }); var owner = cmpOwnership.GetOwner(); if (owner == INVALID_PLAYER || this.cp[owner] > 0) return; // If all cp has been taken from the owner, convert it to the best player. var bestPlayer = 0; for (let i in this.cp) if (this.cp[i] >= this.cp[bestPlayer]) bestPlayer = +i; let cmpLostPlayerStatisticsTracker = QueryOwnerInterface(this.entity, IID_StatisticsTracker); if (cmpLostPlayerStatisticsTracker) cmpLostPlayerStatisticsTracker.LostEntity(this.entity); cmpOwnership.SetOwner(bestPlayer); let cmpCapturedPlayerStatisticsTracker = QueryOwnerInterface(this.entity, IID_StatisticsTracker); if (cmpCapturedPlayerStatisticsTracker) cmpCapturedPlayerStatisticsTracker.CapturedEntity(this.entity); }; Capturable.prototype.GetRegenRate = function() { var regenRate = +this.template.RegenRate; regenRate = ApplyValueModificationsToEntity("Capturable/RegenRate", regenRate, this.entity); var cmpGarrisonHolder = Engine.QueryInterface(this.entity, IID_GarrisonHolder); if (cmpGarrisonHolder) var garrisonRegenRate = this.GetGarrisonRegenRate() * cmpGarrisonHolder.GetEntities().length; else var garrisonRegenRate = 0; return regenRate + garrisonRegenRate; }; Capturable.prototype.TimerTick = function() { var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || cmpOwnership.GetOwner() == INVALID_PLAYER) return; var owner = cmpOwnership.GetOwner(); var modifiedCp = 0; // Special handle for the territory decay. // Reduce cp from the owner in favour of all neighbours (also allies). var cmpTerritoryDecay = Engine.QueryInterface(this.entity, IID_TerritoryDecay); if (cmpTerritoryDecay && cmpTerritoryDecay.IsDecaying()) { var neighbours = cmpTerritoryDecay.GetConnectedNeighbours(); var totalNeighbours = neighbours.reduce((a, b) => a + b); var decay = Math.min(cmpTerritoryDecay.GetDecayRate(), this.cp[owner]); this.cp[owner] -= decay; if (totalNeighbours) for (let p in neighbours) this.cp[p] += decay * neighbours[p] / totalNeighbours; else // decay to gaia as default this.cp[0] += decay; modifiedCp += decay; this.RegisterCapturePointsChanged(); } var regenRate = this.GetRegenRate(); if (regenRate < 0) modifiedCp += this.Reduce(-regenRate, 0); else if (regenRate > 0) modifiedCp += this.Reduce(regenRate, owner); if (modifiedCp) return; // Nothing changed, stop the timer. var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.CancelTimer(this.timer); this.timer = 0; Engine.PostMessage(this.entity, MT_CaptureRegenStateChanged, { "regenerating": false, "regenRate": 0, "territoryDecay": 0 }); }; /** * Start the regeneration timer when no timer exists. * When nothing can be modified (f.e. because it is fully regenerated), the * timer stops automatically after one execution. */ Capturable.prototype.CheckTimer = function() { if (this.timer) return; var regenRate = this.GetRegenRate(); var cmpDecay = Engine.QueryInterface(this.entity, IID_TerritoryDecay); var decay = cmpDecay && cmpDecay.IsDecaying() ? cmpDecay.GetDecayRate() : 0; if (regenRate == 0 && decay == 0) return; var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); this.timer = cmpTimer.SetInterval(this.entity, IID_Capturable, "TimerTick", 1000, 1000, null); Engine.PostMessage(this.entity, MT_CaptureRegenStateChanged, { "regenerating": true, "regenRate": regenRate, "territoryDecay": decay }); }; //// Message Listeners //// Capturable.prototype.OnValueModification = function(msg) { if (msg.component != "Capturable") return; var oldMaxCp = this.GetMaxCapturePoints(); this.maxCp = ApplyValueModificationsToEntity("Capturable/CapturePoints", +this.template.CapturePoints, this.entity); if (oldMaxCp == this.maxCp) return; var scale = this.maxCp / oldMaxCp; for (let i in this.cp) this.cp[i] *= scale; Engine.PostMessage(this.entity, MT_CapturePointsChanged, { "capturePoints": this.cp }); this.CheckTimer(); }; Capturable.prototype.OnGarrisonedUnitsChanged = function(msg) { this.CheckTimer(); }; Capturable.prototype.OnTerritoryDecayChanged = function(msg) { if (msg.to) this.CheckTimer(); }; Capturable.prototype.OnDiplomacyChanged = function(msg) { this.CheckTimer(); }; Capturable.prototype.OnOwnershipChanged = function(msg) { if (msg.to == INVALID_PLAYER) return; // we're dead if (this.cp.length) { if (!this.cp[msg.from]) return; // nothing to change // Was already initialised, this happens on defeat or wololo // transfer the points of the old owner to the new one this.cp[msg.to] += this.cp[msg.from]; this.cp[msg.from] = 0; this.RegisterCapturePointsChanged(); } else { // Initialise the capture points when created. let numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetNumPlayers(); for (let i = 0; i < numPlayers; ++i) if (i == msg.to) this.cp[i] = this.maxCp; else this.cp[i] = 0; } this.CheckTimer(); }; /** * When a player is defeated, reassign the cp of non-owned entities to gaia. * Those owned by the defeated player are dealt with onOwnershipChanged. */ Capturable.prototype.OnGlobalPlayerDefeated = function(msg) { if (!this.cp[msg.playerId]) return; let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (cmpOwnership && cmpOwnership.GetOwner() == msg.playerId) return; this.cp[0] += this.cp[msg.playerId]; this.cp[msg.playerId] = 0; this.RegisterCapturePointsChanged(); this.CheckTimer(); }; Engine.RegisterComponentType(IID_Capturable, "Capturable", Capturable); Index: ps/trunk/binaries/data/mods/public/simulation/components/DeathDamage.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/DeathDamage.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/DeathDamage.js (revision 22754) @@ -1,95 +1,77 @@ function DeathDamage() {} DeathDamage.prototype.bonusesSchema = "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; DeathDamage.prototype.Schema = "When a unit or building is destroyed, it inflicts damage to nearby units." + "" + "Circular" + "20" + "false" + "" + "0.0" + "10.0" + "50.0" + "" + "" + "" + "" + "" + - "" + - BuildDamageTypesSchema("damage strength") + - "" + - DeathDamage.prototype.bonusesSchema; + Attacking.BuildAttackEffectsSchema(); DeathDamage.prototype.Init = function() { }; DeathDamage.prototype.Serialize = null; // we have no dynamic state to save -DeathDamage.prototype.GetDeathDamageStrengths = function() +DeathDamage.prototype.GetDeathDamageEffects = function() { - // Work out the damage values with technology effects - let applyMods = damageType => - ApplyValueModificationsToEntity("DeathDamage/Damage/" + damageType, +(this.template.Damage[damageType] || 0), this.entity); - - let ret = {}; - for (let damageType in this.template.Damage) - ret[damageType] = applyMods(damageType); - - return ret; -}; - -DeathDamage.prototype.GetBonusTemplate = function() -{ - return this.template.Bonuses || null; + return Attacking.GetAttackEffectsData("DeathDamage", this.template, this.entity); }; DeathDamage.prototype.CauseDeathDamage = function() { let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return; let pos = cmpPosition.GetPosition2D(); let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); let owner = cmpOwnership.GetOwner(); if (owner == INVALID_PLAYER) warn("Unit causing death damage does not have any owner."); - let cmpDamage = Engine.QueryInterface(SYSTEM_ENTITY, IID_Damage); - let playersToDamage = cmpDamage.GetPlayersToDamage(owner, this.template.FriendlyFire); + let playersToDamage = Attacking.GetPlayersToDamage(owner, this.template.FriendlyFire); let radius = ApplyValueModificationsToEntity("DeathDamage/Range", +this.template.Range, this.entity); - cmpDamage.CauseDamageOverArea({ + Attacking.CauseDamageOverArea({ + "type": "Death", + "attackData": this.GetDeathDamageEffects(), "attacker": this.entity, + "attackerOwner": owner, "origin": pos, "radius": radius, "shape": this.template.Shape, - "strengths": this.GetDeathDamageStrengths(), - "splashBonus": this.GetBonusTemplate(), "playersToDamage": playersToDamage, - "type": "Death", - "attackerOwner": owner }); }; Engine.RegisterComponentType(IID_DeathDamage, "DeathDamage", DeathDamage); Index: ps/trunk/binaries/data/mods/public/simulation/components/DelayedDamage.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/DelayedDamage.js (nonexistent) +++ ps/trunk/binaries/data/mods/public/simulation/components/DelayedDamage.js (revision 22754) @@ -0,0 +1,85 @@ +function DelayedDamage() {} + +DelayedDamage.prototype.Schema = + ""; + +DelayedDamage.prototype.Init = function() +{ +}; + +/** + * Handles hit logic after the projectile travel time has passed. + * @param {Object} data - The data sent by the caller. + * @param {string} data.type - The type of damage. + * @param {Object} data.attackData - Data of the form { 'effectType': { ...opaque effect data... }, 'Bonuses': {...} }. + * @param {number} data.target - The entity id of the target. + * @param {number} data.attacker - The entity id of the attacker. + * @param {number} data.attackerOwner - The player id of the owner of the attacker. + * @param {Vector2D} data.origin - The origin of the projectile hit. + * @param {Vector3D} data.position - The expected position of the target. + * @param {number} data.projectileId - The id of the projectile. + * @param {Vector3D} data.direction - The unit vector defining the direction. + * @param {string} data.attackImpactSound - The name of the sound emited on impact. + * ***When splash damage*** + * @param {boolean} data.splash.friendlyFire - A flag indicating if allied entities are also damaged. + * @param {number} data.splash.radius - The radius of the splash damage. + * @param {string} data.splash.shape - The shape of the splash range. + * @param {Object} data.splash.attackData - same as attackData, for splash. + */ +DelayedDamage.prototype.MissileHit = function(data, lateness) +{ + if (!data.position) + return; + + let cmpSoundManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_SoundManager); + if (cmpSoundManager && data.attackImpactSound) + cmpSoundManager.PlaySoundGroupAtPosition(data.attackImpactSound, data.position); + + // Do this first in case the direct hit kills the target + if (data.splash) + { + Attacking.CauseDamageOverArea({ + "type": data.type, + "attackData": data.splash.attackData, + "attacker": data.attacker, + "attackerOwner": data.attackerOwner, + "origin": Vector2D.from3D(data.position), + "radius": data.splash.radius, + "shape": data.splash.shape, + "direction": data.direction, + "playersToDamage": Attacking.GetPlayersToDamage(data.attackerOwner, data.splash.friendlyFire) + }); + } + + let cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager); + + // Deal direct damage if we hit the main target + // and if the target has Resistance (not the case for a mirage for example) + if (Attacking.TestCollision(data.target, data.position, lateness)) + { + cmpProjectileManager.RemoveProjectile(data.projectileId); + + Attacking.HandleAttackEffects(data.type, data.attackData, data.target, data.attacker, data.attackerOwner); + return; + } + + let targetPosition = Attacking.InterpolatedLocation(data.target, lateness); + if (!targetPosition) + return; + + // If we didn't hit the main target look for nearby units + let cmpPlayer = QueryPlayerIDInterface(data.attackerOwner); + let ents = Attacking.EntitiesNearPoint(Vector2D.from3D(data.position), targetPosition.horizDistanceTo(data.position) * 2, cmpPlayer.GetEnemies()); + for (let ent of ents) + { + if (!Attacking.TestCollision(ent, data.position, lateness)) + continue; + + Attacking.HandleAttackEffects(data.type, data.attackData, ent, data.attacker, data.attackerOwner); + + cmpProjectileManager.RemoveProjectile(data.projectileId); + break; + } +}; + +Engine.RegisterSystemComponentType(IID_DelayedDamage, "DelayedDamage", DelayedDamage); Property changes on: ps/trunk/binaries/data/mods/public/simulation/components/DelayedDamage.js ___________________________________________________________________ Added: svn:eol-style ## -0,0 +1 ## +native \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/simulation/components/GuiInterface.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/GuiInterface.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/GuiInterface.js (revision 22754) @@ -1,1980 +1,1980 @@ function GuiInterface() {} GuiInterface.prototype.Schema = ""; GuiInterface.prototype.Serialize = function() { // This component isn't network-synchronised for the biggest part // So most of the attributes shouldn't be serialized // Return an object with a small selection of deterministic data return { "timeNotifications": this.timeNotifications, "timeNotificationID": this.timeNotificationID }; }; GuiInterface.prototype.Deserialize = function(data) { this.Init(); this.timeNotifications = data.timeNotifications; this.timeNotificationID = data.timeNotificationID; }; GuiInterface.prototype.Init = function() { this.placementEntity = undefined; // = undefined or [templateName, entityID] this.placementWallEntities = undefined; this.placementWallLastAngle = 0; this.notifications = []; this.renamedEntities = []; this.miragedEntities = []; this.timeNotificationID = 1; this.timeNotifications = []; this.entsRallyPointsDisplayed = []; this.entsWithAuraAndStatusBars = new Set(); this.enabledVisualRangeOverlayTypes = {}; this.templateModified = {}; }; /* * All of the functions defined below are called via Engine.GuiInterfaceCall(name, arg) * from GUI scripts, and executed here with arguments (player, arg). * * CAUTION: The input to the functions in this module is not network-synchronised, so it * mustn't affect the simulation state (i.e. the data that is serialised and can affect * the behaviour of the rest of the simulation) else it'll cause out-of-sync errors. */ /** * Returns global information about the current game state. * This is used by the GUI and also by AI scripts. */ GuiInterface.prototype.GetSimulationState = function() { let ret = { "players": [] }; let numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetNumPlayers(); for (let i = 0; i < numPlayers; ++i) { let cmpPlayer = QueryPlayerIDInterface(i); let cmpPlayerEntityLimits = QueryPlayerIDInterface(i, IID_EntityLimits); // Work out what phase we are in let phase = ""; let cmpTechnologyManager = QueryPlayerIDInterface(i, IID_TechnologyManager); if (cmpTechnologyManager) { if (cmpTechnologyManager.IsTechnologyResearched("phase_city")) phase = "city"; else if (cmpTechnologyManager.IsTechnologyResearched("phase_town")) phase = "town"; else if (cmpTechnologyManager.IsTechnologyResearched("phase_village")) phase = "village"; } // store player ally/neutral/enemy data as arrays let allies = []; let mutualAllies = []; let neutrals = []; let enemies = []; for (let j = 0; j < numPlayers; ++j) { allies[j] = cmpPlayer.IsAlly(j); mutualAllies[j] = cmpPlayer.IsMutualAlly(j); neutrals[j] = cmpPlayer.IsNeutral(j); enemies[j] = cmpPlayer.IsEnemy(j); } ret.players.push({ "name": cmpPlayer.GetName(), "civ": cmpPlayer.GetCiv(), "color": cmpPlayer.GetColor(), "controlsAll": cmpPlayer.CanControlAllUnits(), "popCount": cmpPlayer.GetPopulationCount(), "popLimit": cmpPlayer.GetPopulationLimit(), "popMax": cmpPlayer.GetMaxPopulation(), "panelEntities": cmpPlayer.GetPanelEntities(), "resourceCounts": cmpPlayer.GetResourceCounts(), "trainingBlocked": cmpPlayer.IsTrainingBlocked(), "state": cmpPlayer.GetState(), "team": cmpPlayer.GetTeam(), "teamsLocked": cmpPlayer.GetLockTeams(), "cheatsEnabled": cmpPlayer.GetCheatsEnabled(), "disabledTemplates": cmpPlayer.GetDisabledTemplates(), "disabledTechnologies": cmpPlayer.GetDisabledTechnologies(), "hasSharedDropsites": cmpPlayer.HasSharedDropsites(), "hasSharedLos": cmpPlayer.HasSharedLos(), "spyCostMultiplier": cmpPlayer.GetSpyCostMultiplier(), "phase": phase, "isAlly": allies, "isMutualAlly": mutualAllies, "isNeutral": neutrals, "isEnemy": enemies, "entityLimits": cmpPlayerEntityLimits ? cmpPlayerEntityLimits.GetLimits() : null, "entityCounts": cmpPlayerEntityLimits ? cmpPlayerEntityLimits.GetCounts() : null, "entityLimitChangers": cmpPlayerEntityLimits ? cmpPlayerEntityLimits.GetLimitChangers() : null, "researchQueued": cmpTechnologyManager ? cmpTechnologyManager.GetQueuedResearch() : null, "researchStarted": cmpTechnologyManager ? cmpTechnologyManager.GetStartedTechs() : null, "researchedTechs": cmpTechnologyManager ? cmpTechnologyManager.GetResearchedTechs() : null, "classCounts": cmpTechnologyManager ? cmpTechnologyManager.GetClassCounts() : null, "typeCountsByClass": cmpTechnologyManager ? cmpTechnologyManager.GetTypeCountsByClass() : null, "canBarter": Engine.QueryInterface(SYSTEM_ENTITY, IID_Barter).PlayerHasMarket(i), "barterPrices": Engine.QueryInterface(SYSTEM_ENTITY, IID_Barter).GetPrices(i) }); } let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (cmpRangeManager) ret.circularMap = cmpRangeManager.GetLosCircular(); let cmpTerrain = Engine.QueryInterface(SYSTEM_ENTITY, IID_Terrain); if (cmpTerrain) ret.mapSize = cmpTerrain.GetMapSize(); // Add timeElapsed let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); ret.timeElapsed = cmpTimer.GetTime(); // Add ceasefire info let cmpCeasefireManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_CeasefireManager); if (cmpCeasefireManager) { ret.ceasefireActive = cmpCeasefireManager.IsCeasefireActive(); ret.ceasefireTimeRemaining = ret.ceasefireActive ? cmpCeasefireManager.GetCeasefireStartedTime() + cmpCeasefireManager.GetCeasefireTime() - ret.timeElapsed : 0; } // Add cinema path info let cmpCinemaManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_CinemaManager); if (cmpCinemaManager) ret.cinemaPlaying = cmpCinemaManager.IsPlaying(); // Add the game type and allied victory let cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager); ret.victoryConditions = cmpEndGameManager.GetVictoryConditions(); ret.alliedVictory = cmpEndGameManager.GetAlliedVictory(); // Add basic statistics to each player for (let i = 0; i < numPlayers; ++i) { let cmpPlayerStatisticsTracker = QueryPlayerIDInterface(i, IID_StatisticsTracker); if (cmpPlayerStatisticsTracker) ret.players[i].statistics = cmpPlayerStatisticsTracker.GetBasicStatistics(); } return ret; }; /** * Returns global information about the current game state, plus statistics. * This is used by the GUI at the end of a game, in the summary screen. * Note: Amongst statistics, the team exploration map percentage is computed from * scratch, so the extended simulation state should not be requested too often. */ GuiInterface.prototype.GetExtendedSimulationState = function() { // Get basic simulation info let ret = this.GetSimulationState(); // Add statistics to each player let numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetNumPlayers(); for (let i = 0; i < numPlayers; ++i) { let cmpPlayerStatisticsTracker = QueryPlayerIDInterface(i, IID_StatisticsTracker); if (cmpPlayerStatisticsTracker) ret.players[i].sequences = cmpPlayerStatisticsTracker.GetSequences(); } return ret; }; GuiInterface.prototype.GetRenamedEntities = function(player) { if (this.miragedEntities[player]) return this.renamedEntities.concat(this.miragedEntities[player]); return this.renamedEntities; }; GuiInterface.prototype.ClearRenamedEntities = function() { this.renamedEntities = []; this.miragedEntities = []; }; GuiInterface.prototype.AddMiragedEntity = function(player, entity, mirage) { if (!this.miragedEntities[player]) this.miragedEntities[player] = []; this.miragedEntities[player].push({ "entity": entity, "newentity": mirage }); }; /** * Get common entity info, often used in the gui */ GuiInterface.prototype.GetEntityState = function(player, ent) { let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); // All units must have a template; if not then it's a nonexistent entity id let template = cmpTemplateManager.GetCurrentTemplateName(ent); if (!template) return null; let ret = { "id": ent, "player": INVALID_PLAYER, "template": template }; let cmpMirage = Engine.QueryInterface(ent, IID_Mirage); if (cmpMirage) ret.mirage = true; let cmpIdentity = Engine.QueryInterface(ent, IID_Identity); if (cmpIdentity) ret.identity = { "rank": cmpIdentity.GetRank(), "classes": cmpIdentity.GetClassesList(), "visibleClasses": cmpIdentity.GetVisibleClassesList(), "selectionGroupName": cmpIdentity.GetSelectionGroupName(), "canDelete": !cmpIdentity.IsUndeletable() }; let cmpPosition = Engine.QueryInterface(ent, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) ret.position = cmpPosition.GetPosition(); let cmpHealth = QueryMiragedInterface(ent, IID_Health); if (cmpHealth) { ret.hitpoints = cmpHealth.GetHitpoints(); ret.maxHitpoints = cmpHealth.GetMaxHitpoints(); ret.needsRepair = cmpHealth.IsRepairable() && cmpHealth.IsInjured(); ret.needsHeal = !cmpHealth.IsUnhealable(); } let cmpCapturable = QueryMiragedInterface(ent, IID_Capturable); if (cmpCapturable) { ret.capturePoints = cmpCapturable.GetCapturePoints(); ret.maxCapturePoints = cmpCapturable.GetMaxCapturePoints(); } let cmpBuilder = Engine.QueryInterface(ent, IID_Builder); if (cmpBuilder) ret.builder = true; let cmpMarket = QueryMiragedInterface(ent, IID_Market); if (cmpMarket) ret.market = { "land": cmpMarket.HasType("land"), "naval": cmpMarket.HasType("naval"), }; let cmpPack = Engine.QueryInterface(ent, IID_Pack); if (cmpPack) ret.pack = { "packed": cmpPack.IsPacked(), "progress": cmpPack.GetProgress(), }; var cmpUpgrade = Engine.QueryInterface(ent, IID_Upgrade); if (cmpUpgrade) ret.upgrade = { "upgrades": cmpUpgrade.GetUpgrades(), "progress": cmpUpgrade.GetProgress(), "template": cmpUpgrade.GetUpgradingTo() }; let cmpProductionQueue = Engine.QueryInterface(ent, IID_ProductionQueue); if (cmpProductionQueue) ret.production = { "entities": cmpProductionQueue.GetEntitiesList(), "technologies": cmpProductionQueue.GetTechnologiesList(), "techCostMultiplier": cmpProductionQueue.GetTechCostMultiplier(), "queue": cmpProductionQueue.GetQueue() }; let cmpTrader = Engine.QueryInterface(ent, IID_Trader); if (cmpTrader) ret.trader = { "goods": cmpTrader.GetGoods() }; let cmpFoundation = QueryMiragedInterface(ent, IID_Foundation); if (cmpFoundation) ret.foundation = { "numBuilders": cmpFoundation.GetNumBuilders(), "buildTime": cmpFoundation.GetBuildTime() }; let cmpRepairable = QueryMiragedInterface(ent, IID_Repairable); if (cmpRepairable) ret.repairable = { "numBuilders": cmpRepairable.GetNumBuilders(), "buildTime": cmpRepairable.GetBuildTime() }; let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership); if (cmpOwnership) ret.player = cmpOwnership.GetOwner(); let cmpRallyPoint = Engine.QueryInterface(ent, IID_RallyPoint); if (cmpRallyPoint) ret.rallyPoint = { "position": cmpRallyPoint.GetPositions()[0] }; // undefined or {x,z} object let cmpGarrisonHolder = Engine.QueryInterface(ent, IID_GarrisonHolder); if (cmpGarrisonHolder) ret.garrisonHolder = { "entities": cmpGarrisonHolder.GetEntities(), "buffHeal": cmpGarrisonHolder.GetHealRate(), "allowedClasses": cmpGarrisonHolder.GetAllowedClasses(), "capacity": cmpGarrisonHolder.GetCapacity(), "garrisonedEntitiesCount": cmpGarrisonHolder.GetGarrisonedEntitiesCount() }; ret.canGarrison = !!Engine.QueryInterface(ent, IID_Garrisonable); let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); if (cmpUnitAI) ret.unitAI = { "state": cmpUnitAI.GetCurrentState(), "orders": cmpUnitAI.GetOrders(), "hasWorkOrders": cmpUnitAI.HasWorkOrders(), "canGuard": cmpUnitAI.CanGuard(), "isGuarding": cmpUnitAI.IsGuardOf(), "canPatrol": cmpUnitAI.CanPatrol(), "selectableStances": cmpUnitAI.GetSelectableStances(), "isIdle": cmpUnitAI.IsIdle(), }; let cmpGuard = Engine.QueryInterface(ent, IID_Guard); if (cmpGuard) ret.guard = { "entities": cmpGuard.GetEntities(), }; let cmpResourceGatherer = Engine.QueryInterface(ent, IID_ResourceGatherer); if (cmpResourceGatherer) { ret.resourceCarrying = cmpResourceGatherer.GetCarryingStatus(); ret.resourceGatherRates = cmpResourceGatherer.GetGatherRates(); } let cmpGate = Engine.QueryInterface(ent, IID_Gate); if (cmpGate) ret.gate = { "locked": cmpGate.IsLocked(), }; let cmpAlertRaiser = Engine.QueryInterface(ent, IID_AlertRaiser); if (cmpAlertRaiser) ret.alertRaiser = true; let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); ret.visibility = cmpRangeManager.GetLosVisibility(ent, player); let cmpAttack = Engine.QueryInterface(ent, IID_Attack); if (cmpAttack) { let types = cmpAttack.GetAttackTypes(); if (types.length) ret.attack = {}; for (let type of types) { ret.attack[type] = {}; - if (type == "Capture") - ret.attack[type] = cmpAttack.GetAttackStrengths(type); - else - ret.attack[type].damage = cmpAttack.GetAttackStrengths(type); + + Object.assign(ret.attack[type], cmpAttack.GetAttackEffectsData(type)); ret.attack[type].splash = cmpAttack.GetSplashDamage(type); + if (ret.attack[type].splash) + Object.assign(ret.attack[type].splash, cmpAttack.GetAttackEffectsData(type, true)); let range = cmpAttack.GetRange(type); ret.attack[type].minRange = range.min; ret.attack[type].maxRange = range.max; let timers = cmpAttack.GetTimers(type); ret.attack[type].prepareTime = timers.prepare; ret.attack[type].repeatTime = timers.repeat; if (type != "Ranged") { // not a ranged attack, set some defaults ret.attack[type].elevationBonus = 0; ret.attack[type].elevationAdaptedRange = ret.attack.maxRange; continue; } ret.attack[type].elevationBonus = range.elevationBonus; if (cmpUnitAI && cmpPosition && cmpPosition.IsInWorld()) { // For units, take the range in front of it, no spread. So angle = 0 ret.attack[type].elevationAdaptedRange = cmpRangeManager.GetElevationAdaptedRange(cmpPosition.GetPosition(), cmpPosition.GetRotation(), range.max, range.elevationBonus, 0); } else if(cmpPosition && cmpPosition.IsInWorld()) { // For buildings, take the average elevation around it. So angle = 2*pi ret.attack[type].elevationAdaptedRange = cmpRangeManager.GetElevationAdaptedRange(cmpPosition.GetPosition(), cmpPosition.GetRotation(), range.max, range.elevationBonus, 2*Math.PI); } else { // not in world, set a default? ret.attack[type].elevationAdaptedRange = ret.attack.maxRange; } } } - let cmpArmour = Engine.QueryInterface(ent, IID_DamageReceiver); + let cmpArmour = Engine.QueryInterface(ent, IID_Resistance); if (cmpArmour) - ret.armour = cmpArmour.GetArmourStrengths(); + ret.armour = cmpArmour.GetArmourStrengths("Damage"); let cmpBuildingAI = Engine.QueryInterface(ent, IID_BuildingAI); if (cmpBuildingAI) ret.buildingAI = { "defaultArrowCount": cmpBuildingAI.GetDefaultArrowCount(), "maxArrowCount": cmpBuildingAI.GetMaxArrowCount(), "garrisonArrowMultiplier": cmpBuildingAI.GetGarrisonArrowMultiplier(), "garrisonArrowClasses": cmpBuildingAI.GetGarrisonArrowClasses(), "arrowCount": cmpBuildingAI.GetArrowCount() }; if (cmpPosition && cmpPosition.GetTurretParent() != INVALID_ENTITY) ret.turretParent = cmpPosition.GetTurretParent(); let cmpResourceSupply = QueryMiragedInterface(ent, IID_ResourceSupply); if (cmpResourceSupply) ret.resourceSupply = { "isInfinite": cmpResourceSupply.IsInfinite(), "max": cmpResourceSupply.GetMaxAmount(), "amount": cmpResourceSupply.GetCurrentAmount(), "type": cmpResourceSupply.GetType(), "killBeforeGather": cmpResourceSupply.GetKillBeforeGather(), "maxGatherers": cmpResourceSupply.GetMaxGatherers(), "numGatherers": cmpResourceSupply.GetNumGatherers() }; let cmpResourceDropsite = Engine.QueryInterface(ent, IID_ResourceDropsite); if (cmpResourceDropsite) ret.resourceDropsite = { "types": cmpResourceDropsite.GetTypes(), "sharable": cmpResourceDropsite.IsSharable(), "shared": cmpResourceDropsite.IsShared() }; let cmpPromotion = Engine.QueryInterface(ent, IID_Promotion); if (cmpPromotion) ret.promotion = { "curr": cmpPromotion.GetCurrentXp(), "req": cmpPromotion.GetRequiredXp() }; if (!cmpFoundation && cmpIdentity && cmpIdentity.HasClass("BarterMarket")) ret.isBarterMarket = true; let cmpHeal = Engine.QueryInterface(ent, IID_Heal); if (cmpHeal) ret.heal = { "hp": cmpHeal.GetHP(), "range": cmpHeal.GetRange().max, "rate": cmpHeal.GetRate(), "unhealableClasses": cmpHeal.GetUnhealableClasses(), "healableClasses": cmpHeal.GetHealableClasses(), }; let cmpLoot = Engine.QueryInterface(ent, IID_Loot); if (cmpLoot) { ret.loot = cmpLoot.GetResources(); ret.loot.xp = cmpLoot.GetXp(); } let cmpResourceTrickle = Engine.QueryInterface(ent, IID_ResourceTrickle); if (cmpResourceTrickle) ret.resourceTrickle = { "interval": cmpResourceTrickle.GetTimer(), "rates": cmpResourceTrickle.GetRates() }; let cmpUnitMotion = Engine.QueryInterface(ent, IID_UnitMotion); if (cmpUnitMotion) ret.speed = { "walk": cmpUnitMotion.GetWalkSpeed(), "run": cmpUnitMotion.GetWalkSpeed() * cmpUnitMotion.GetRunMultiplier() }; return ret; }; GuiInterface.prototype.GetMultipleEntityStates = function(player, ents) { return ents.map(ent => ({ "entId": ent, "state": this.GetEntityState(player, ent) })); }; GuiInterface.prototype.GetAverageRangeForBuildings = function(player, cmd) { let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); let cmpTerrain = Engine.QueryInterface(SYSTEM_ENTITY, IID_Terrain); let rot = { "x": 0, "y": 0, "z": 0 }; let pos = { "x": cmd.x, "y": cmpTerrain.GetGroundLevel(cmd.x, cmd.z), "z": cmd.z }; let elevationBonus = cmd.elevationBonus || 0; let range = cmd.range; return cmpRangeManager.GetElevationAdaptedRange(pos, rot, range, elevationBonus, 2*Math.PI); }; GuiInterface.prototype.GetTemplateData = function(player, templateName) { let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); let template = cmpTemplateManager.GetTemplate(templateName); if (!template) return null; let aurasTemplate = {}; if (!template.Auras) return GetTemplateDataHelper(template, player, aurasTemplate, Resources); let auraNames = template.Auras._string.split(/\s+/); for (let name of auraNames) aurasTemplate[name] = AuraTemplates.Get(name); return GetTemplateDataHelper(template, player, aurasTemplate, Resources); }; GuiInterface.prototype.IsTechnologyResearched = function(player, data) { if (!data.tech) return true; let cmpTechnologyManager = QueryPlayerIDInterface(data.player || player, IID_TechnologyManager); if (!cmpTechnologyManager) return false; return cmpTechnologyManager.IsTechnologyResearched(data.tech); }; // Checks whether the requirements for this technology have been met GuiInterface.prototype.CheckTechnologyRequirements = function(player, data) { let cmpTechnologyManager = QueryPlayerIDInterface(data.player || player, IID_TechnologyManager); if (!cmpTechnologyManager) return false; return cmpTechnologyManager.CanResearch(data.tech); }; // Returns technologies that are being actively researched, along with // which entity is researching them and how far along the research is. GuiInterface.prototype.GetStartedResearch = function(player) { let cmpTechnologyManager = QueryPlayerIDInterface(player, IID_TechnologyManager); if (!cmpTechnologyManager) return {}; let ret = {}; for (let tech of cmpTechnologyManager.GetStartedTechs()) { ret[tech] = { "researcher": cmpTechnologyManager.GetResearcher(tech) }; let cmpProductionQueue = Engine.QueryInterface(ret[tech].researcher, IID_ProductionQueue); if (cmpProductionQueue) { ret[tech].progress = cmpProductionQueue.GetQueue()[0].progress; ret[tech].timeRemaining = cmpProductionQueue.GetQueue()[0].timeRemaining; } else { ret[tech].progress = 0; ret[tech].timeRemaining = 0; } } return ret; }; // Returns the battle state of the player. GuiInterface.prototype.GetBattleState = function(player) { let cmpBattleDetection = QueryPlayerIDInterface(player, IID_BattleDetection); if (!cmpBattleDetection) return false; return cmpBattleDetection.GetState(); }; // Returns a list of ongoing attacks against the player. GuiInterface.prototype.GetIncomingAttacks = function(player) { return QueryPlayerIDInterface(player, IID_AttackDetection).GetIncomingAttacks(); }; // Used to show a red square over GUI elements you can't yet afford. GuiInterface.prototype.GetNeededResources = function(player, data) { return QueryPlayerIDInterface(data.player || player).GetNeededResources(data.cost); }; /** * State of the templateData (player dependent): true when some template values have been modified * and need to be reloaded by the gui. */ GuiInterface.prototype.OnTemplateModification = function(msg) { this.templateModified[msg.player] = true; }; GuiInterface.prototype.IsTemplateModified = function(player) { return this.templateModified[player] || false; }; GuiInterface.prototype.ResetTemplateModified = function() { this.templateModified = {}; }; /** * Add a timed notification. * Warning: timed notifacations are serialised * (to also display them on saved games or after a rejoin) * so they should allways be added and deleted in a deterministic way. */ GuiInterface.prototype.AddTimeNotification = function(notification, duration = 10000) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); notification.endTime = duration + cmpTimer.GetTime(); notification.id = ++this.timeNotificationID; // Let all players and observers receive the notification by default if (!notification.players) { notification.players = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetAllPlayers(); notification.players[0] = -1; } this.timeNotifications.push(notification); this.timeNotifications.sort((n1, n2) => n2.endTime - n1.endTime); cmpTimer.SetTimeout(this.entity, IID_GuiInterface, "DeleteTimeNotification", duration, this.timeNotificationID); return this.timeNotificationID; }; GuiInterface.prototype.DeleteTimeNotification = function(notificationID) { this.timeNotifications = this.timeNotifications.filter(n => n.id != notificationID); }; GuiInterface.prototype.GetTimeNotifications = function(player) { let time = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer).GetTime(); // filter on players and time, since the delete timer might be executed with a delay return this.timeNotifications.filter(n => n.players.indexOf(player) != -1 && n.endTime > time); }; GuiInterface.prototype.PushNotification = function(notification) { if (!notification.type || notification.type == "text") this.AddTimeNotification(notification); else this.notifications.push(notification); }; GuiInterface.prototype.GetNotifications = function() { let n = this.notifications; this.notifications = []; return n; }; GuiInterface.prototype.GetAvailableFormations = function(player, wantedPlayer) { return QueryPlayerIDInterface(wantedPlayer).GetFormations(); }; GuiInterface.prototype.GetFormationRequirements = function(player, data) { return GetFormationRequirements(data.formationTemplate); }; GuiInterface.prototype.CanMoveEntsIntoFormation = function(player, data) { return CanMoveEntsIntoFormation(data.ents, data.formationTemplate); }; GuiInterface.prototype.GetFormationInfoFromTemplate = function(player, data) { let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); let template = cmpTemplateManager.GetTemplate(data.templateName); if (!template || !template.Formation) return {}; return { "name": template.Formation.FormationName, "tooltip": template.Formation.DisabledTooltip || "", "icon": template.Formation.Icon }; }; GuiInterface.prototype.IsFormationSelected = function(player, data) { return data.ents.some(ent => { let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); return cmpUnitAI && cmpUnitAI.GetFormationTemplate() == data.formationTemplate; }); }; GuiInterface.prototype.IsStanceSelected = function(player, data) { for (let ent of data.ents) { let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); if (cmpUnitAI && cmpUnitAI.GetStanceName() == data.stance) return true; } return false; }; GuiInterface.prototype.GetAllBuildableEntities = function(player, cmd) { let buildableEnts = []; for (let ent of cmd.entities) { let cmpBuilder = Engine.QueryInterface(ent, IID_Builder); if (!cmpBuilder) continue; for (let building of cmpBuilder.GetEntitiesList()) if (buildableEnts.indexOf(building) == -1) buildableEnts.push(building); } return buildableEnts; }; GuiInterface.prototype.UpdateDisplayedPlayerColors = function(player, data) { let updateEntityColor = (iids, entities) => { for (let ent of entities) for (let iid of iids) { let cmp = Engine.QueryInterface(ent, iid); if (cmp) cmp.UpdateColor(); } }; let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); let numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetNumPlayers(); for (let i = 1; i < numPlayers; ++i) { let cmpPlayer = QueryPlayerIDInterface(i, IID_Player); if (!cmpPlayer) continue; cmpPlayer.SetDisplayDiplomacyColor(data.displayDiplomacyColors); if (data.displayDiplomacyColors) cmpPlayer.SetDiplomacyColor(data.displayedPlayerColors[i]); updateEntityColor(data.showAllStatusBars && (i == player || player == -1) ? [IID_Minimap, IID_RangeOverlayRenderer, IID_RallyPointRenderer, IID_StatusBars] : [IID_Minimap, IID_RangeOverlayRenderer, IID_RallyPointRenderer], cmpRangeManager.GetEntitiesByPlayer(i)); } updateEntityColor([IID_Selectable, IID_StatusBars], data.selected); Engine.QueryInterface(SYSTEM_ENTITY, IID_TerritoryManager).UpdateColors(); }; GuiInterface.prototype.SetSelectionHighlight = function(player, cmd) { let playerColors = {}; // cache of owner -> color map for (let ent of cmd.entities) { let cmpSelectable = Engine.QueryInterface(ent, IID_Selectable); if (!cmpSelectable) continue; // Find the entity's owner's color: let owner = INVALID_PLAYER; let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership); if (cmpOwnership) owner = cmpOwnership.GetOwner(); let color = playerColors[owner]; if (!color) { color = { "r": 1, "g": 1, "b": 1 }; let cmpPlayer = QueryPlayerIDInterface(owner); if (cmpPlayer) color = cmpPlayer.GetDisplayedColor(); playerColors[owner] = color; } cmpSelectable.SetSelectionHighlight({ "r": color.r, "g": color.g, "b": color.b, "a": cmd.alpha }, cmd.selected); let cmpRangeOverlayManager = Engine.QueryInterface(ent, IID_RangeOverlayManager); if (!cmpRangeOverlayManager || player != owner && player != INVALID_PLAYER) continue; cmpRangeOverlayManager.SetEnabled(cmd.selected, this.enabledVisualRangeOverlayTypes, false); } }; GuiInterface.prototype.EnableVisualRangeOverlayType = function(player, data) { this.enabledVisualRangeOverlayTypes[data.type] = data.enabled; }; GuiInterface.prototype.GetEntitiesWithStatusBars = function() { return Array.from(this.entsWithAuraAndStatusBars); }; GuiInterface.prototype.SetStatusBars = function(player, cmd) { let affectedEnts = new Set(); for (let ent of cmd.entities) { let cmpStatusBars = Engine.QueryInterface(ent, IID_StatusBars); if (!cmpStatusBars) continue; cmpStatusBars.SetEnabled(cmd.enabled, cmd.showRank, cmd.showExperience); let cmpAuras = Engine.QueryInterface(ent, IID_Auras); if (!cmpAuras) continue; for (let name of cmpAuras.GetAuraNames()) { if (!cmpAuras.GetOverlayIcon(name)) continue; for (let e of cmpAuras.GetAffectedEntities(name)) affectedEnts.add(e); if (cmd.enabled) this.entsWithAuraAndStatusBars.add(ent); else this.entsWithAuraAndStatusBars.delete(ent); } } for (let ent of affectedEnts) { let cmpStatusBars = Engine.QueryInterface(ent, IID_StatusBars); if (cmpStatusBars) cmpStatusBars.RegenerateSprites(); } }; GuiInterface.prototype.SetRangeOverlays = function(player, cmd) { for (let ent of cmd.entities) { let cmpRangeOverlayManager = Engine.QueryInterface(ent, IID_RangeOverlayManager); if (cmpRangeOverlayManager) cmpRangeOverlayManager.SetEnabled(cmd.enabled, this.enabledVisualRangeOverlayTypes, true); } }; GuiInterface.prototype.GetPlayerEntities = function(player) { return Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).GetEntitiesByPlayer(player); }; GuiInterface.prototype.GetNonGaiaEntities = function() { return Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).GetNonGaiaEntities(); }; /** * Displays the rally points of a given list of entities (carried in cmd.entities). * * The 'cmd' object may carry its own x/z coordinate pair indicating the location where the rally point should * be rendered, in order to support instantaneously rendering a rally point marker at a specified location * instead of incurring a delay while PostNetworkCommand processes the set-rallypoint command (see input.js). * If cmd doesn't carry a custom location, then the position to render the marker at will be read from the * RallyPoint component. */ GuiInterface.prototype.DisplayRallyPoint = function(player, cmd) { let cmpPlayer = QueryPlayerIDInterface(player); // If there are some rally points already displayed, first hide them for (let ent of this.entsRallyPointsDisplayed) { let cmpRallyPointRenderer = Engine.QueryInterface(ent, IID_RallyPointRenderer); if (cmpRallyPointRenderer) cmpRallyPointRenderer.SetDisplayed(false); } this.entsRallyPointsDisplayed = []; // Show the rally points for the passed entities for (let ent of cmd.entities) { let cmpRallyPointRenderer = Engine.QueryInterface(ent, IID_RallyPointRenderer); if (!cmpRallyPointRenderer) continue; // entity must have a rally point component to display a rally point marker // (regardless of whether cmd specifies a custom location) let cmpRallyPoint = Engine.QueryInterface(ent, IID_RallyPoint); if (!cmpRallyPoint) continue; // Verify the owner let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership); if (!(cmpPlayer && cmpPlayer.CanControlAllUnits())) if (!cmpOwnership || cmpOwnership.GetOwner() != player) continue; // If the command was passed an explicit position, use that and // override the real rally point position; otherwise use the real position let pos; if (cmd.x && cmd.z) pos = cmd; else pos = cmpRallyPoint.GetPositions()[0]; // may return undefined if no rally point is set if (pos) { // Only update the position if we changed it (cmd.queued is set) if ("queued" in cmd) if (cmd.queued == true) cmpRallyPointRenderer.AddPosition({ 'x': pos.x, 'y': pos.z }); // AddPosition takes a CFixedVector2D which has X/Y components, not X/Z else cmpRallyPointRenderer.SetPosition({ 'x': pos.x, 'y': pos.z }); // SetPosition takes a CFixedVector2D which has X/Y components, not X/Z // rebuild the renderer when not set (when reading saved game or in case of building update) else if (!cmpRallyPointRenderer.IsSet()) for (let posi of cmpRallyPoint.GetPositions()) cmpRallyPointRenderer.AddPosition({ 'x': posi.x, 'y': posi.z }); cmpRallyPointRenderer.SetDisplayed(true); // remember which entities have their rally points displayed so we can hide them again this.entsRallyPointsDisplayed.push(ent); } } }; GuiInterface.prototype.AddTargetMarker = function(player, cmd) { let ent = Engine.AddLocalEntity(cmd.template); if (!ent) return; let cmpPosition = Engine.QueryInterface(ent, IID_Position); cmpPosition.JumpTo(cmd.x, cmd.z); }; /** * Display the building placement preview. * cmd.template is the name of the entity template, or "" to disable the preview. * cmd.x, cmd.z, cmd.angle give the location. * * Returns result object from CheckPlacement: * { * "success": true iff the placement is valid, else false * "message": message to display in UI for invalid placement, else "" * "parameters": parameters to use in the message * "translateMessage": localisation info * "translateParameters": localisation info * "pluralMessage": we might return a plural translation instead (optional) * "pluralCount": localisation info (optional) * } */ GuiInterface.prototype.SetBuildingPlacementPreview = function(player, cmd) { let result = { "success": false, "message": "", "parameters": {}, "translateMessage": false, "translateParameters": [], }; // See if we're changing template if (!this.placementEntity || this.placementEntity[0] != cmd.template) { // Destroy the old preview if there was one if (this.placementEntity) Engine.DestroyEntity(this.placementEntity[1]); // Load the new template if (cmd.template == "") this.placementEntity = undefined; else this.placementEntity = [cmd.template, Engine.AddLocalEntity("preview|" + cmd.template)]; } if (this.placementEntity) { let ent = this.placementEntity[1]; // Move the preview into the right location let pos = Engine.QueryInterface(ent, IID_Position); if (pos) { pos.JumpTo(cmd.x, cmd.z); pos.SetYRotation(cmd.angle); } let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership); cmpOwnership.SetOwner(player); // Check whether building placement is valid let cmpBuildRestrictions = Engine.QueryInterface(ent, IID_BuildRestrictions); if (!cmpBuildRestrictions) error("cmpBuildRestrictions not defined"); else result = cmpBuildRestrictions.CheckPlacement(); let cmpRangeOverlayManager = Engine.QueryInterface(ent, IID_RangeOverlayManager); if (cmpRangeOverlayManager) cmpRangeOverlayManager.SetEnabled(true, this.enabledVisualRangeOverlayTypes); // Set it to a red shade if this is an invalid location let cmpVisual = Engine.QueryInterface(ent, IID_Visual); if (cmpVisual) { if (cmd.actorSeed !== undefined) cmpVisual.SetActorSeed(cmd.actorSeed); if (!result.success) cmpVisual.SetShadingColor(1.4, 0.4, 0.4, 1); else cmpVisual.SetShadingColor(1, 1, 1, 1); } } return result; }; /** * Previews the placement of a wall between cmd.start and cmd.end, or just the starting piece of a wall if cmd.end is not * specified. Returns an object with information about the list of entities that need to be newly constructed to complete * at least a part of the wall, or false if there are entities required to build at least part of the wall but none of * them can be validly constructed. * * It's important to distinguish between three lists of entities that are at play here, because they may be subsets of one * another depending on things like snapping and whether some of the entities inside them can be validly positioned. * We have: * - The list of entities that previews the wall. This list is usually equal to the entities required to construct the * entire wall. However, if there is snapping to an incomplete tower (i.e. a foundation), it includes extra entities * to preview the completed tower on top of its foundation. * * - The list of entities that need to be newly constructed to build the entire wall. This list is regardless of whether * any of them can be validly positioned. The emphasishere here is on 'newly'; this list does not include any existing * towers at either side of the wall that we snapped to. Or, more generally; it does not include any _entities_ that we * snapped to; we might still snap to e.g. terrain, in which case the towers on either end will still need to be newly * constructed. * * - The list of entities that need to be newly constructed to build at least a part of the wall. This list is the same * as the one above, except that it is truncated at the first entity that cannot be validly positioned. This happens * e.g. if the player tries to build a wall straight through an obstruction. Note that any entities that can be validly * constructed but come after said first invalid entity are also truncated away. * * With this in mind, this method will return false if the second list is not empty, but the third one is. That is, if there * were entities that are needed to build the wall, but none of them can be validly constructed. False is also returned in * case of unexpected errors (typically missing components), and when clearing the preview by passing an empty wallset * argument (see below). Otherwise, it will return an object with the following information: * * result: { * 'startSnappedEnt': ID of the entity that we snapped to at the starting side of the wall. Currently only supports towers. * 'endSnappedEnt': ID of the entity that we snapped to at the (possibly truncated) ending side of the wall. Note that this * can only be set if no truncation of the second list occurs; if we snapped to an entity at the ending side * but the wall construction was truncated before we could reach it, it won't be set here. Currently only * supports towers. * 'pieces': Array with the following data for each of the entities in the third list: * [{ * 'template': Template name of the entity. * 'x': X coordinate of the entity's position. * 'z': Z coordinate of the entity's position. * 'angle': Rotation around the Y axis of the entity (in radians). * }, * ...] * 'cost': { The total cost required for constructing all the pieces as listed above. * 'food': ..., * 'wood': ..., * 'stone': ..., * 'metal': ..., * 'population': ..., * 'populationBonus': ..., * } * } * * @param cmd.wallSet Object holding the set of wall piece template names. Set to an empty value to clear the preview. * @param cmd.start Starting point of the wall segment being created. * @param cmd.end (Optional) Ending point of the wall segment being created. If not defined, it is understood that only * the starting point of the wall is available at this time (e.g. while the player is still in the process * of picking a starting point), and that therefore only the first entity in the wall (a tower) should be * previewed. * @param cmd.snapEntities List of candidate entities to snap the start and ending positions to. */ GuiInterface.prototype.SetWallPlacementPreview = function(player, cmd) { let wallSet = cmd.wallSet; let start = { "pos": cmd.start, "angle": 0, "snapped": false, // did the start position snap to anything? "snappedEnt": INVALID_ENTITY, // if we snapped, was it to an entity? if yes, holds that entity's ID }; let end = { "pos": cmd.end, "angle": 0, "snapped": false, // did the start position snap to anything? "snappedEnt": INVALID_ENTITY, // if we snapped, was it to an entity? if yes, holds that entity's ID }; // -------------------------------------------------------------------------------- // do some entity cache management and check for snapping if (!this.placementWallEntities) this.placementWallEntities = {}; if (!wallSet) { // we're clearing the preview, clear the entity cache and bail for (let tpl in this.placementWallEntities) { for (let ent of this.placementWallEntities[tpl].entities) Engine.DestroyEntity(ent); this.placementWallEntities[tpl].numUsed = 0; this.placementWallEntities[tpl].entities = []; // keep template data around } return false; } // Move all existing cached entities outside of the world and reset their use count for (let tpl in this.placementWallEntities) { for (let ent of this.placementWallEntities[tpl].entities) { let pos = Engine.QueryInterface(ent, IID_Position); if (pos) pos.MoveOutOfWorld(); } this.placementWallEntities[tpl].numUsed = 0; } // Create cache entries for templates we haven't seen before for (let type in wallSet.templates) { if (type == "curves") continue; let tpl = wallSet.templates[type]; if (!(tpl in this.placementWallEntities)) { this.placementWallEntities[tpl] = { "numUsed": 0, "entities": [], "templateData": this.GetTemplateData(player, tpl), }; // ensure that the loaded template data contains a wallPiece component if (!this.placementWallEntities[tpl].templateData.wallPiece) { error("[SetWallPlacementPreview] No WallPiece component found for wall set template '" + tpl + "'"); return false; } } } // prevent division by zero errors further on if the start and end positions are the same if (end.pos && (start.pos.x === end.pos.x && start.pos.z === end.pos.z)) end.pos = undefined; // See if we need to snap the start and/or end coordinates to any of our list of snap entities. Note that, despite the list // of snapping candidate entities, it might still snap to e.g. terrain features. Use the "ent" key in the returned snapping // data to determine whether it snapped to an entity (if any), and to which one (see GetFoundationSnapData). if (cmd.snapEntities) { let snapRadius = this.placementWallEntities[wallSet.templates.tower].templateData.wallPiece.length * 0.5; // determined through trial and error let startSnapData = this.GetFoundationSnapData(player, { "x": start.pos.x, "z": start.pos.z, "template": wallSet.templates.tower, "snapEntities": cmd.snapEntities, "snapRadius": snapRadius, }); if (startSnapData) { start.pos.x = startSnapData.x; start.pos.z = startSnapData.z; start.angle = startSnapData.angle; start.snapped = true; if (startSnapData.ent) start.snappedEnt = startSnapData.ent; } if (end.pos) { let endSnapData = this.GetFoundationSnapData(player, { "x": end.pos.x, "z": end.pos.z, "template": wallSet.templates.tower, "snapEntities": cmd.snapEntities, "snapRadius": snapRadius, }); if (endSnapData) { end.pos.x = endSnapData.x; end.pos.z = endSnapData.z; end.angle = endSnapData.angle; end.snapped = true; if (endSnapData.ent) end.snappedEnt = endSnapData.ent; } } } // clear the single-building preview entity (we'll be rolling our own) this.SetBuildingPlacementPreview(player, { "template": "" }); // -------------------------------------------------------------------------------- // calculate wall placement and position preview entities let result = { "pieces": [], "cost": { "population": 0, "populationBonus": 0, "time": 0 }, }; for (let res of Resources.GetCodes()) result.cost[res] = 0; let previewEntities = []; if (end.pos) previewEntities = GetWallPlacement(this.placementWallEntities, wallSet, start, end); // see helpers/Walls.js // For wall placement, we may (and usually do) need to have wall pieces overlap each other more than would // otherwise be allowed by their obstruction shapes. However, during this preview phase, this is not so much of // an issue, because all preview entities have their obstruction components deactivated, meaning that their // obstruction shapes do not register in the simulation and hence cannot affect it. This implies that the preview // entities cannot be found to obstruct each other, which largely solves the issue of overlap between wall pieces. // Note that they will still be obstructed by existing shapes in the simulation (that have the BLOCK_FOUNDATION // flag set), which is what we want. The only exception to this is when snapping to existing towers (or // foundations thereof); the wall segments that connect up to these will be found to be obstructed by the // existing tower/foundation, and be shaded red to indicate that they cannot be placed there. To prevent this, // we manually set the control group of the outermost wall pieces equal to those of the snapped-to towers, so // that they are free from mutual obstruction (per definition of obstruction control groups). This is done by // assigning them an extra "controlGroup" field, which we'll then set during the placement loop below. // Additionally, in the situation that we're snapping to merely a foundation of a tower instead of a fully // constructed one, we'll need an extra preview entity for the starting tower, which also must not be obstructed // by the foundation it snaps to. if (start.snappedEnt && start.snappedEnt != INVALID_ENTITY) { let startEntObstruction = Engine.QueryInterface(start.snappedEnt, IID_Obstruction); if (previewEntities.length > 0 && startEntObstruction) previewEntities[0].controlGroups = [startEntObstruction.GetControlGroup()]; // if we're snapping to merely a foundation, add an extra preview tower and also set it to the same control group let startEntState = this.GetEntityState(player, start.snappedEnt); if (startEntState.foundation) { let cmpPosition = Engine.QueryInterface(start.snappedEnt, IID_Position); if (cmpPosition) previewEntities.unshift({ "template": wallSet.templates.tower, "pos": start.pos, "angle": cmpPosition.GetRotation().y, "controlGroups": [startEntObstruction ? startEntObstruction.GetControlGroup() : undefined], "excludeFromResult": true, // preview only, must not appear in the result }); } } else { // Didn't snap to an existing entity, add the starting tower manually. To prevent odd-looking rotation jumps // when shift-clicking to build a wall, reuse the placement angle that was last seen on a validly positioned // wall piece. // To illustrate the last point, consider what happens if we used some constant instead, say, 0. Issuing the // build command for a wall is asynchronous, so when the preview updates after shift-clicking, the wall piece // foundations are not registered yet in the simulation. This means they cannot possibly be picked in the list // of candidate entities for snapping. In the next preview update, we therefore hit this case, and would rotate // the preview to 0 radians. Then, after one or two simulation updates or so, the foundations register and // onSimulationUpdate in session.js updates the preview again. It first grabs a new list of snapping candidates, // which this time does include the new foundations; so we snap to the entity, and rotate the preview back to // the foundation's angle. // The result is a noticeable rotation to 0 and back, which is undesirable. So, for a split second there until // the simulation updates, we fake it by reusing the last angle and hope the player doesn't notice. previewEntities.unshift({ "template": wallSet.templates.tower, "pos": start.pos, "angle": previewEntities.length > 0 ? previewEntities[0].angle : this.placementWallLastAngle }); } if (end.pos) { // Analogous to the starting side case above if (end.snappedEnt && end.snappedEnt != INVALID_ENTITY) { let endEntObstruction = Engine.QueryInterface(end.snappedEnt, IID_Obstruction); // Note that it's possible for the last entity in previewEntities to be the same as the first, i.e. the // same wall piece snapping to both a starting and an ending tower. And it might be more common than you would // expect; the allowed overlap between wall segments and towers facilitates this to some degree. To deal with // the possibility of dual initial control groups, we use a '.controlGroups' array rather than a single // '.controlGroup' property. Note that this array can only ever have 0, 1 or 2 elements (checked at a later time). if (previewEntities.length > 0 && endEntObstruction) { previewEntities[previewEntities.length-1].controlGroups = previewEntities[previewEntities.length-1].controlGroups || []; previewEntities[previewEntities.length-1].controlGroups.push(endEntObstruction.GetControlGroup()); } // if we're snapping to a foundation, add an extra preview tower and also set it to the same control group let endEntState = this.GetEntityState(player, end.snappedEnt); if (endEntState.foundation) { let cmpPosition = Engine.QueryInterface(end.snappedEnt, IID_Position); if (cmpPosition) previewEntities.push({ "template": wallSet.templates.tower, "pos": end.pos, "angle": cmpPosition.GetRotation().y, "controlGroups": [endEntObstruction ? endEntObstruction.GetControlGroup() : undefined], "excludeFromResult": true }); } } else previewEntities.push({ "template": wallSet.templates.tower, "pos": end.pos, "angle": previewEntities.length > 0 ? previewEntities[previewEntities.length-1].angle : this.placementWallLastAngle }); } let cmpTerrain = Engine.QueryInterface(SYSTEM_ENTITY, IID_Terrain); if (!cmpTerrain) { error("[SetWallPlacementPreview] System Terrain component not found"); return false; } let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (!cmpRangeManager) { error("[SetWallPlacementPreview] System RangeManager component not found"); return false; } // Loop through the preview entities, and construct the subset of them that need to be, and can be, validly constructed // to build at least a part of the wall (meaning that the subset is truncated after the first entity that needs to be, // but cannot validly be, constructed). See method-level documentation for more details. let allPiecesValid = true; let numRequiredPieces = 0; // number of entities that are required to build the entire wall, regardless of validity for (let i = 0; i < previewEntities.length; ++i) { let entInfo = previewEntities[i]; let ent = null; let tpl = entInfo.template; let tplData = this.placementWallEntities[tpl].templateData; let entPool = this.placementWallEntities[tpl]; if (entPool.numUsed >= entPool.entities.length) { // allocate new entity ent = Engine.AddLocalEntity("preview|" + tpl); entPool.entities.push(ent); } else // reuse an existing one ent = entPool.entities[entPool.numUsed]; if (!ent) { error("[SetWallPlacementPreview] Failed to allocate or reuse preview entity of template '" + tpl + "'"); continue; } // move piece to right location // TODO: consider reusing SetBuildingPlacementReview for this, enhanced to be able to deal with multiple entities let cmpPosition = Engine.QueryInterface(ent, IID_Position); if (cmpPosition) { cmpPosition.JumpTo(entInfo.pos.x, entInfo.pos.z); cmpPosition.SetYRotation(entInfo.angle); // if this piece is a tower, then it should have a Y position that is at least as high as its surrounding pieces if (tpl === wallSet.templates.tower) { let terrainGroundPrev = null; let terrainGroundNext = null; if (i > 0) terrainGroundPrev = cmpTerrain.GetGroundLevel(previewEntities[i-1].pos.x, previewEntities[i-1].pos.z); if (i < previewEntities.length - 1) terrainGroundNext = cmpTerrain.GetGroundLevel(previewEntities[i+1].pos.x, previewEntities[i+1].pos.z); if (terrainGroundPrev != null || terrainGroundNext != null) { let targetY = Math.max(terrainGroundPrev, terrainGroundNext); cmpPosition.SetHeightFixed(targetY); } } } let cmpObstruction = Engine.QueryInterface(ent, IID_Obstruction); if (!cmpObstruction) { error("[SetWallPlacementPreview] Preview entity of template '" + tpl + "' does not have an Obstruction component"); continue; } // Assign any predefined control groups. Note that there can only be 0, 1 or 2 predefined control groups; if there are // more, we've made a programming error. The control groups are assigned from the entInfo.controlGroups array on a // first-come first-served basis; the first value in the array is always assigned as the primary control group, and // any second value as the secondary control group. // By default, we reset the control groups to their standard values. Remember that we're reusing entities; if we don't // reset them, then an ending wall segment that was e.g. at one point snapped to an existing tower, and is subsequently // reused as a non-snapped ending wall segment, would no longer be capable of being obstructed by the same tower it was // once snapped to. let primaryControlGroup = ent; let secondaryControlGroup = INVALID_ENTITY; if (entInfo.controlGroups && entInfo.controlGroups.length > 0) { if (entInfo.controlGroups.length > 2) { error("[SetWallPlacementPreview] Encountered preview entity of template '" + tpl + "' with more than 2 initial control groups"); break; } primaryControlGroup = entInfo.controlGroups[0]; if (entInfo.controlGroups.length > 1) secondaryControlGroup = entInfo.controlGroups[1]; } cmpObstruction.SetControlGroup(primaryControlGroup); cmpObstruction.SetControlGroup2(secondaryControlGroup); // check whether this wall piece can be validly positioned here let validPlacement = false; let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership); cmpOwnership.SetOwner(player); // Check whether it's in a visible or fogged region // TODO: should definitely reuse SetBuildingPlacementPreview, this is just straight up copy/pasta let visible = cmpRangeManager.GetLosVisibility(ent, player) != "hidden"; if (visible) { let cmpBuildRestrictions = Engine.QueryInterface(ent, IID_BuildRestrictions); if (!cmpBuildRestrictions) { error("[SetWallPlacementPreview] cmpBuildRestrictions not defined for preview entity of template '" + tpl + "'"); continue; } // TODO: Handle results of CheckPlacement validPlacement = cmpBuildRestrictions && cmpBuildRestrictions.CheckPlacement().success; // If a wall piece has two control groups, it's likely a segment that spans // between two existing towers. To avoid placing a duplicate wall segment, // check for collisions with entities that share both control groups. if (validPlacement && entInfo.controlGroups && entInfo.controlGroups.length > 1) validPlacement = cmpObstruction.CheckDuplicateFoundation(); } allPiecesValid = allPiecesValid && validPlacement; // The requirement below that all pieces so far have to have valid positions, rather than only this single one, // ensures that no more foundations will be placed after a first invalidly-positioned piece. (It is possible // for pieces past some invalidly-positioned ones to still have valid positions, e.g. if you drag a wall // through and past an existing building). // Additionally, the excludeFromResult flag is set for preview entities that were manually added to be placed // on top of foundations of incompleted towers that we snapped to; they must not be part of the result. if (!entInfo.excludeFromResult) ++numRequiredPieces; if (allPiecesValid && !entInfo.excludeFromResult) { result.pieces.push({ "template": tpl, "x": entInfo.pos.x, "z": entInfo.pos.z, "angle": entInfo.angle, }); this.placementWallLastAngle = entInfo.angle; // grab the cost of this wall piece and add it up (note; preview entities don't have their Cost components // copied over, so we need to fetch it from the template instead). // TODO: we should really use a Cost object or at least some utility functions for this, this is mindless // boilerplate that's probably duplicated in tons of places. for (let res of Resources.GetCodes().concat(["population", "populationBonus", "time"])) result.cost[res] += tplData.cost[res]; } let canAfford = true; let cmpPlayer = QueryPlayerIDInterface(player, IID_Player); if (cmpPlayer && cmpPlayer.GetNeededResources(result.cost)) canAfford = false; let cmpVisual = Engine.QueryInterface(ent, IID_Visual); if (cmpVisual) { if (!allPiecesValid || !canAfford) cmpVisual.SetShadingColor(1.4, 0.4, 0.4, 1); else cmpVisual.SetShadingColor(1, 1, 1, 1); } ++entPool.numUsed; } // If any were entities required to build the wall, but none of them could be validly positioned, return failure // (see method-level documentation). if (numRequiredPieces > 0 && result.pieces.length == 0) return false; if (start.snappedEnt && start.snappedEnt != INVALID_ENTITY) result.startSnappedEnt = start.snappedEnt; // We should only return that we snapped to an entity if all pieces up until that entity can be validly constructed, // i.e. are included in result.pieces (see docs for the result object). if (end.pos && end.snappedEnt && end.snappedEnt != INVALID_ENTITY && allPiecesValid) result.endSnappedEnt = end.snappedEnt; return result; }; /** * Given the current position {data.x, data.z} of an foundation of template data.template, returns the position and angle to snap * it to (if necessary/useful). * * @param data.x The X position of the foundation to snap. * @param data.z The Z position of the foundation to snap. * @param data.template The template to get the foundation snapping data for. * @param data.snapEntities Optional; list of entity IDs to snap to if {data.x, data.z} is within a circle of radius data.snapRadius * around the entity. Only takes effect when used in conjunction with data.snapRadius. * When this option is used and the foundation is found to snap to one of the entities passed in this list * (as opposed to e.g. snapping to terrain features), then the result will contain an additional key "ent", * holding the ID of the entity that was snapped to. * @param data.snapRadius Optional; when used in conjunction with data.snapEntities, indicates the circle radius around an entity that * {data.x, data.z} must be located within to have it snap to that entity. */ GuiInterface.prototype.GetFoundationSnapData = function(player, data) { let template = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager).GetTemplate(data.template); if (!template) { warn("[GetFoundationSnapData] Failed to load template '" + data.template + "'"); return false; } if (data.snapEntities && data.snapRadius && data.snapRadius > 0) { // see if {data.x, data.z} is inside the snap radius of any of the snap entities; and if so, to which it is closest // (TODO: break unlikely ties by choosing the lowest entity ID) let minDist2 = -1; let minDistEntitySnapData = null; let radius2 = data.snapRadius * data.snapRadius; for (let ent of data.snapEntities) { let cmpPosition = Engine.QueryInterface(ent, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) continue; let pos = cmpPosition.GetPosition(); let dist2 = (data.x - pos.x) * (data.x - pos.x) + (data.z - pos.z) * (data.z - pos.z); if (dist2 > radius2) continue; if (minDist2 < 0 || dist2 < minDist2) { minDist2 = dist2; minDistEntitySnapData = { "x": pos.x, "z": pos.z, "angle": cmpPosition.GetRotation().y, "ent": ent }; } } if (minDistEntitySnapData != null) return minDistEntitySnapData; } if (template.BuildRestrictions.PlacementType == "shore") { let angle = GetDockAngle(template, data.x, data.z); if (angle !== undefined) return { "x": data.x, "z": data.z, "angle": angle }; } return false; }; GuiInterface.prototype.PlaySound = function(player, data) { if (!data.entity) return; PlaySound(data.name, data.entity); }; /** * Find any idle units. * * @param data.idleClasses Array of class names to include. * @param data.prevUnit The previous idle unit, if calling a second time to iterate through units. May be left undefined. * @param data.limit The number of idle units to return. May be left undefined (will return all idle units). * @param data.excludeUnits Array of units to exclude. * * Returns an array of idle units. * If multiple classes were supplied, and multiple items will be returned, the items will be sorted by class. */ GuiInterface.prototype.FindIdleUnits = function(player, data) { let idleUnits = []; // The general case is that only the 'first' idle unit is required; filtering would examine every unit. // This loop imitates a grouping/aggregation on the first matching idle class. let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); for (let entity of cmpRangeManager.GetEntitiesByPlayer(player)) { let filtered = this.IdleUnitFilter(entity, data.idleClasses, data.excludeUnits); if (!filtered.idle) continue; // If the entity is in the 'current' (first, 0) bucket on a resumed search, it must be after the "previous" unit, if any. // By adding to the 'end', there is no pause if the series of units loops. var bucket = filtered.bucket; if(bucket == 0 && data.prevUnit && entity <= data.prevUnit) bucket = data.idleClasses.length; if (!idleUnits[bucket]) idleUnits[bucket] = []; idleUnits[bucket].push(entity); // If enough units have been collected in the first bucket, go ahead and return them. if (data.limit && bucket == 0 && idleUnits[0].length == data.limit) return idleUnits[0]; } let reduced = idleUnits.reduce((prev, curr) => prev.concat(curr), []); if (data.limit && reduced.length > data.limit) return reduced.slice(0, data.limit); return reduced; }; /** * Discover if the player has idle units. * * @param data.idleClasses Array of class names to include. * @param data.excludeUnits Array of units to exclude. * * Returns a boolean of whether the player has any idle units */ GuiInterface.prototype.HasIdleUnits = function(player, data) { let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); return cmpRangeManager.GetEntitiesByPlayer(player).some(unit => this.IdleUnitFilter(unit, data.idleClasses, data.excludeUnits).idle); }; /** * Whether to filter an idle unit * * @param unit The unit to filter. * @param idleclasses Array of class names to include. * @param excludeUnits Array of units to exclude. * * Returns an object with the following fields: * - idle - true if the unit is considered idle by the filter, false otherwise. * - bucket - if idle, set to the index of the first matching idle class, undefined otherwise. */ GuiInterface.prototype.IdleUnitFilter = function(unit, idleClasses, excludeUnits) { let cmpUnitAI = Engine.QueryInterface(unit, IID_UnitAI); if (!cmpUnitAI || !cmpUnitAI.IsIdle() || cmpUnitAI.IsGarrisoned()) return { "idle": false }; let cmpIdentity = Engine.QueryInterface(unit, IID_Identity); if(!cmpIdentity) return { "idle": false }; let bucket = idleClasses.findIndex(elem => MatchesClassList(cmpIdentity.GetClassesList(), elem)); if (bucket == -1 || excludeUnits.indexOf(unit) > -1) return { "idle": false }; return { "idle": true, "bucket": bucket }; }; GuiInterface.prototype.GetTradingRouteGain = function(player, data) { if (!data.firstMarket || !data.secondMarket) return null; return CalculateTraderGain(data.firstMarket, data.secondMarket, data.template); }; GuiInterface.prototype.GetTradingDetails = function(player, data) { let cmpEntityTrader = Engine.QueryInterface(data.trader, IID_Trader); if (!cmpEntityTrader || !cmpEntityTrader.CanTrade(data.target)) return null; let firstMarket = cmpEntityTrader.GetFirstMarket(); let secondMarket = cmpEntityTrader.GetSecondMarket(); let result = null; if (data.target === firstMarket) { result = { "type": "is first", "hasBothMarkets": cmpEntityTrader.HasBothMarkets() }; if (cmpEntityTrader.HasBothMarkets()) result.gain = cmpEntityTrader.GetGoods().amount; } else if (data.target === secondMarket) { result = { "type": "is second", "gain": cmpEntityTrader.GetGoods().amount, }; } else if (!firstMarket) { result = { "type": "set first" }; } else if (!secondMarket) { result = { "type": "set second", "gain": cmpEntityTrader.CalculateGain(firstMarket, data.target), }; } else { // Else both markets are not null and target is different from them result = { "type": "set first" }; } return result; }; GuiInterface.prototype.CanAttack = function(player, data) { let cmpAttack = Engine.QueryInterface(data.entity, IID_Attack); return cmpAttack && cmpAttack.CanAttack(data.target, data.types || undefined); }; /* * Returns batch build time. */ GuiInterface.prototype.GetBatchTime = function(player, data) { let cmpProductionQueue = Engine.QueryInterface(data.entity, IID_ProductionQueue); if (!cmpProductionQueue) return 0; return cmpProductionQueue.GetBatchTime(data.batchSize); }; GuiInterface.prototype.IsMapRevealed = function(player) { return Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).GetLosRevealAll(player); }; GuiInterface.prototype.SetPathfinderDebugOverlay = function(player, enabled) { Engine.QueryInterface(SYSTEM_ENTITY, IID_Pathfinder).SetDebugOverlay(enabled); }; GuiInterface.prototype.SetPathfinderHierDebugOverlay = function(player, enabled) { Engine.QueryInterface(SYSTEM_ENTITY, IID_Pathfinder).SetHierDebugOverlay(enabled); }; GuiInterface.prototype.SetObstructionDebugOverlay = function(player, enabled) { Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager).SetDebugOverlay(enabled); }; GuiInterface.prototype.SetMotionDebugOverlay = function(player, data) { for (let ent of data.entities) { let cmpUnitMotion = Engine.QueryInterface(ent, IID_UnitMotion); if (cmpUnitMotion) cmpUnitMotion.SetDebugOverlay(data.enabled); } }; GuiInterface.prototype.SetRangeDebugOverlay = function(player, enabled) { Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).SetDebugOverlay(enabled); }; GuiInterface.prototype.GetTraderNumber = function(player) { let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); let traders = cmpRangeManager.GetEntitiesByPlayer(player).filter(e => Engine.QueryInterface(e, IID_Trader)); let landTrader = { "total": 0, "trading": 0, "garrisoned": 0 }; let shipTrader = { "total": 0, "trading": 0 }; for (let ent of traders) { let cmpIdentity = Engine.QueryInterface(ent, IID_Identity); let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); if (!cmpIdentity || !cmpUnitAI) continue; if (cmpIdentity.HasClass("Ship")) { ++shipTrader.total; if (cmpUnitAI.order && cmpUnitAI.order.type == "Trade") ++shipTrader.trading; } else { ++landTrader.total; if (cmpUnitAI.order && cmpUnitAI.order.type == "Trade") ++landTrader.trading; if (cmpUnitAI.order && cmpUnitAI.order.type == "Garrison") { let holder = cmpUnitAI.order.data.target; let cmpHolderUnitAI = Engine.QueryInterface(holder, IID_UnitAI); if (cmpHolderUnitAI && cmpHolderUnitAI.order && cmpHolderUnitAI.order.type == "Trade") ++landTrader.garrisoned; } } } return { "landTrader": landTrader, "shipTrader": shipTrader }; }; GuiInterface.prototype.GetTradingGoods = function(player) { return QueryPlayerIDInterface(player).GetTradingGoods(); }; GuiInterface.prototype.OnGlobalEntityRenamed = function(msg) { this.renamedEntities.push(msg); }; // List the GuiInterface functions that can be safely called by GUI scripts. // (GUI scripts are non-deterministic and untrusted, so these functions must be // appropriately careful. They are called with a first argument "player", which is // trusted and indicates the player associated with the current client; no data should // be returned unless this player is meant to be able to see it.) let exposedFunctions = { "GetSimulationState": 1, "GetExtendedSimulationState": 1, "GetRenamedEntities": 1, "ClearRenamedEntities": 1, "GetEntityState": 1, "GetMultipleEntityStates": 1, "GetAverageRangeForBuildings": 1, "GetTemplateData": 1, "IsTechnologyResearched": 1, "CheckTechnologyRequirements": 1, "GetStartedResearch": 1, "GetBattleState": 1, "GetIncomingAttacks": 1, "GetNeededResources": 1, "GetNotifications": 1, "GetTimeNotifications": 1, "GetAvailableFormations": 1, "GetFormationRequirements": 1, "CanMoveEntsIntoFormation": 1, "IsFormationSelected": 1, "GetFormationInfoFromTemplate": 1, "IsStanceSelected": 1, "UpdateDisplayedPlayerColors": 1, "SetSelectionHighlight": 1, "GetAllBuildableEntities": 1, "SetStatusBars": 1, "GetPlayerEntities": 1, "GetNonGaiaEntities": 1, "DisplayRallyPoint": 1, "AddTargetMarker": 1, "SetBuildingPlacementPreview": 1, "SetWallPlacementPreview": 1, "GetFoundationSnapData": 1, "PlaySound": 1, "FindIdleUnits": 1, "HasIdleUnits": 1, "GetTradingRouteGain": 1, "GetTradingDetails": 1, "CanAttack": 1, "GetBatchTime": 1, "IsMapRevealed": 1, "SetPathfinderDebugOverlay": 1, "SetPathfinderHierDebugOverlay": 1, "SetObstructionDebugOverlay": 1, "SetMotionDebugOverlay": 1, "SetRangeDebugOverlay": 1, "EnableVisualRangeOverlayType": 1, "SetRangeOverlays": 1, "GetTraderNumber": 1, "GetTradingGoods": 1, "IsTemplateModified": 1, "ResetTemplateModified": 1 }; GuiInterface.prototype.ScriptCall = function(player, name, args) { if (exposedFunctions[name]) return this[name](player, args); throw new Error("Invalid GuiInterface Call name \""+name+"\""); }; Engine.RegisterSystemComponentType(IID_GuiInterface, "GuiInterface", GuiInterface); Index: ps/trunk/binaries/data/mods/public/simulation/components/Health.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/Health.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/Health.js (revision 22754) @@ -1,418 +1,443 @@ function Health() {} Health.prototype.Schema = "Deals with hitpoints and death." + "" + "100" + "1.0" + "0" + "corpse" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "0" + "1" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "vanish" + "corpse" + "remain" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; Health.prototype.Init = function() { // Cache this value so it allows techs to maintain previous health level this.maxHitpoints = +this.template.Max; // Default to , but use if it's undefined or zero // (Allowing 0 initial HP would break our death detection code) this.hitpoints = +(this.template.Initial || this.GetMaxHitpoints()); this.regenRate = ApplyValueModificationsToEntity("Health/RegenRate", +this.template.RegenRate, this.entity); this.idleRegenRate = ApplyValueModificationsToEntity("Health/IdleRegenRate", +this.template.IdleRegenRate, this.entity); this.CheckRegenTimer(); this.UpdateActor(); }; /** * Returns the current hitpoint value. * This is 0 if (and only if) the unit is dead. */ Health.prototype.GetHitpoints = function() { return this.hitpoints; }; Health.prototype.GetMaxHitpoints = function() { return this.maxHitpoints; }; /** * @return {boolean} Whether the units are injured. Dead units are not considered injured. */ Health.prototype.IsInjured = function() { return this.hitpoints > 0 && this.hitpoints < this.GetMaxHitpoints(); }; Health.prototype.SetHitpoints = function(value) { // If we're already dead, don't allow resurrection if (this.hitpoints == 0) return; // Before changing the value, activate Fogging if necessary to hide changes let cmpFogging = Engine.QueryInterface(this.entity, IID_Fogging); if (cmpFogging) cmpFogging.Activate(); let old = this.hitpoints; this.hitpoints = Math.max(1, Math.min(this.GetMaxHitpoints(), value)); let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (cmpRangeManager) cmpRangeManager.SetEntityFlag(this.entity, "injured", this.IsInjured()); this.RegisterHealthChanged(old); }; Health.prototype.IsRepairable = function() { return Engine.QueryInterface(this.entity, IID_Repairable) != null; }; Health.prototype.IsUnhealable = function() { return this.template.Unhealable == "true" || this.hitpoints <= 0 || !this.IsInjured(); }; Health.prototype.GetIdleRegenRate = function() { return this.idleRegenRate; }; Health.prototype.GetRegenRate = function() { return this.regenRate; }; Health.prototype.ExecuteRegeneration = function() { let regen = this.GetRegenRate(); if (this.GetIdleRegenRate() != 0) { let cmpUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI); if (cmpUnitAI && (cmpUnitAI.IsIdle() || cmpUnitAI.IsGarrisoned() && !cmpUnitAI.IsTurret())) regen += this.GetIdleRegenRate(); } if (regen > 0) this.Increase(regen); else this.Reduce(-regen); }; /* * Check if the regeneration timer needs to be started or stopped */ Health.prototype.CheckRegenTimer = function() { // check if we need a timer if (this.GetRegenRate() == 0 && this.GetIdleRegenRate() == 0 || !this.IsInjured() && this.GetRegenRate() >= 0 && this.GetIdleRegenRate() >= 0 || this.hitpoints == 0) { // we don't need a timer, disable if one exists if (this.regenTimer) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.CancelTimer(this.regenTimer); this.regenTimer = undefined; } return; } // we need a timer, enable if one doesn't exist if (this.regenTimer) return; let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); this.regenTimer = cmpTimer.SetInterval(this.entity, IID_Health, "ExecuteRegeneration", 1000, 1000, null); }; Health.prototype.Kill = function() { this.Reduce(this.hitpoints); }; /** + * Take damage according to the entity's resistance. + * @param {Object} strengths - { "hack": number, "pierce": number, "crush": number } or something like that. + * @param {number} bonusMultiplier - the damage multiplier. + * Returns object of the form { "killed": false, "change": -12 }. + */ +Health.prototype.TakeDamage = function(effectData, attacker, attackerOwner, bonusMultiplier) +{ + let cmpResistance = Engine.QueryInterface(this.entity, IID_Resistance); + + if (cmpResistance && cmpResistance.IsInvulnerable()) + return { "killed": false }; + + let total = Attacking.GetTotalAttackEffects(effectData, "Damage", cmpResistance) * bonusMultiplier; + + // Reduce health + let change = this.Reduce(total); + + let cmpLoot = Engine.QueryInterface(this.entity, IID_Loot); + if (cmpLoot && cmpLoot.GetXp() > 0 && change.HPchange < 0) + change.xp = cmpLoot.GetXp() * -change.HPchange / this.GetMaxHitpoints(); + + return change; +}; + +/** * @param {number} amount - The amount of hitpoints to substract. Kills the entity if required. - * @return {{killed:boolean, change:number}} - Number of health points lost and whether the entity was killed. + * @return {{killed:boolean, HPchange:number}} - Number of health points lost and whether the entity was killed. */ Health.prototype.Reduce = function(amount) { // If we are dead, do not do anything // (The entity will exist a little while after calling DestroyEntity so this // might get called multiple times) // Likewise if the amount is 0. if (!amount || !this.hitpoints) - return { "killed": false, "change": 0 }; + return { "killed": false, "HPchange": 0 }; // Before changing the value, activate Fogging if necessary to hide changes let cmpFogging = Engine.QueryInterface(this.entity, IID_Fogging); if (cmpFogging) cmpFogging.Activate(); let oldHitpoints = this.hitpoints; // If we reached 0, then die. if (amount >= this.hitpoints) { this.hitpoints = 0; this.RegisterHealthChanged(oldHitpoints); this.HandleDeath(); - return { "killed": true, "change": -oldHitpoints }; + return { "killed": true, "HPchange": -oldHitpoints }; } // If we are not marked as injured, do it now if (!this.IsInjured()) { let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (cmpRangeManager) cmpRangeManager.SetEntityFlag(this.entity, "injured", true); } this.hitpoints -= amount; this.RegisterHealthChanged(oldHitpoints); - return { "killed": false, "change": this.hitpoints - oldHitpoints }; + return { "killed": false, "HPchange": this.hitpoints - oldHitpoints }; }; /** * Handle what happens when the entity dies. */ Health.prototype.HandleDeath = function() { let cmpDeathDamage = Engine.QueryInterface(this.entity, IID_DeathDamage); if (cmpDeathDamage) cmpDeathDamage.CauseDeathDamage(); PlaySound("death", this.entity); if (this.template.SpawnEntityOnDeath) this.CreateDeathSpawnedEntity(); switch (this.template.DeathType) { case "corpse": this.CreateCorpse(); break; case "remain": { let resource = this.CreateCorpse(true); if (resource != INVALID_ENTITY) Engine.PostMessage(this.entity, MT_EntityRenamed, { "entity": this.entity, "newentity": resource }); break; } case "vanish": break; default: error("Invalid template.DeathType: " + this.template.DeathType); break; } Engine.DestroyEntity(this.entity); }; Health.prototype.Increase = function(amount) { // Before changing the value, activate Fogging if necessary to hide changes let cmpFogging = Engine.QueryInterface(this.entity, IID_Fogging); if (cmpFogging) cmpFogging.Activate(); if (!this.IsInjured()) return { "old": this.hitpoints, "new": this.hitpoints }; // If we're already dead, don't allow resurrection if (this.hitpoints == 0) return undefined; let old = this.hitpoints; this.hitpoints = Math.min(this.hitpoints + amount, this.GetMaxHitpoints()); if (!this.IsInjured()) { let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (cmpRangeManager) cmpRangeManager.SetEntityFlag(this.entity, "injured", false); } this.RegisterHealthChanged(old); return { "old": old, "new": this.hitpoints }; }; Health.prototype.CreateCorpse = function(leaveResources) { // If the unit died while not in the world, don't create any corpse for it // since there's nowhere for the corpse to be placed let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition.IsInWorld()) return INVALID_ENTITY; // Either creates a static local version of the current entity, or a // persistent corpse retaining the ResourceSupply element of the parent. let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); let templateName = cmpTemplateManager.GetCurrentTemplateName(this.entity); let corpse; if (leaveResources) corpse = Engine.AddEntity("resource|" + templateName); else corpse = Engine.AddLocalEntity("corpse|" + templateName); // Copy various parameters so it looks just like us let cmpCorpsePosition = Engine.QueryInterface(corpse, IID_Position); let pos = cmpPosition.GetPosition(); cmpCorpsePosition.JumpTo(pos.x, pos.z); let rot = cmpPosition.GetRotation(); cmpCorpsePosition.SetYRotation(rot.y); cmpCorpsePosition.SetXZRotation(rot.x, rot.z); let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); let cmpCorpseOwnership = Engine.QueryInterface(corpse, IID_Ownership); cmpCorpseOwnership.SetOwner(cmpOwnership.GetOwner()); let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); let cmpCorpseVisual = Engine.QueryInterface(corpse, IID_Visual); cmpCorpseVisual.SetActorSeed(cmpVisual.GetActorSeed()); // Make it fall over cmpCorpseVisual.SelectAnimation("death", true, 1.0); return corpse; }; Health.prototype.CreateDeathSpawnedEntity = function() { // If the unit died while not in the world, don't spawn a death entity for it // since there's nowhere for it to be placed let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition.IsInWorld()) return INVALID_ENTITY; // Create SpawnEntityOnDeath entity let spawnedEntity = Engine.AddLocalEntity(this.template.SpawnEntityOnDeath); // Move to same position let cmpSpawnedPosition = Engine.QueryInterface(spawnedEntity, IID_Position); let pos = cmpPosition.GetPosition(); cmpSpawnedPosition.JumpTo(pos.x, pos.z); let rot = cmpPosition.GetRotation(); cmpSpawnedPosition.SetYRotation(rot.y); cmpSpawnedPosition.SetXZRotation(rot.x, rot.z); let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); let cmpSpawnedOwnership = Engine.QueryInterface(spawnedEntity, IID_Ownership); if (cmpOwnership && cmpSpawnedOwnership) cmpSpawnedOwnership.SetOwner(cmpOwnership.GetOwner()); return spawnedEntity; }; Health.prototype.UpdateActor = function() { if (!this.template.DamageVariants) return; let ratio = this.hitpoints / this.GetMaxHitpoints(); let newDamageVariant = "alive"; if (ratio > 0) { let minTreshold = 1; for (let key in this.template.DamageVariants) { let treshold = +this.template.DamageVariants[key]; if (treshold < ratio || treshold > minTreshold) continue; newDamageVariant = key; minTreshold = treshold; } } else newDamageVariant = "death"; if (this.damageVariant && this.damageVariant == newDamageVariant) return; this.damageVariant = newDamageVariant; let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (cmpVisual) cmpVisual.SetVariant("health", newDamageVariant); }; Health.prototype.OnValueModification = function(msg) { if (msg.component != "Health") return; let oldMaxHitpoints = this.GetMaxHitpoints(); let newMaxHitpoints = ApplyValueModificationsToEntity("Health/Max", +this.template.Max, this.entity); if (oldMaxHitpoints != newMaxHitpoints) { let newHitpoints = this.hitpoints * newMaxHitpoints/oldMaxHitpoints; this.maxHitpoints = newMaxHitpoints; this.SetHitpoints(newHitpoints); } let oldRegenRate = this.regenRate; this.regenRate = ApplyValueModificationsToEntity("Health/RegenRate", +this.template.RegenRate, this.entity); let oldIdleRegenRate = this.idleRegenRate; this.idleRegenRate = ApplyValueModificationsToEntity("Health/IdleRegenRate", +this.template.IdleRegenRate, this.entity); if (this.regenRate != oldRegenRate || this.idleRegenRate != oldIdleRegenRate) this.CheckRegenTimer(); }; Health.prototype.RegisterHealthChanged = function(from) { this.CheckRegenTimer(); this.UpdateActor(); Engine.PostMessage(this.entity, MT_HealthChanged, { "from": from, "to": this.hitpoints }); }; Engine.RegisterComponentType(IID_Health, "Health", Health); Index: ps/trunk/binaries/data/mods/public/simulation/components/StatusEffectsReceiver.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/StatusEffectsReceiver.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/StatusEffectsReceiver.js (revision 22754) @@ -1,69 +1,65 @@ function StatusEffectsReceiver() {} StatusEffectsReceiver.prototype.Init = function() { this.activeStatusEffects = {}; }; -StatusEffectsReceiver.prototype.InflictEffects = function(statusEffects) +// Called by attacking effects. +StatusEffectsReceiver.prototype.GiveStatus = function(effectData, attacker, attackerOwner, bonusMultiplier) { - for (let effect in statusEffects) - this.InflictEffect(effect, statusEffects[effect]); + for (let effect in effectData) + this.AddStatus(effect, effectData[effect]); + + // TODO: implement loot / resistance. + + return { "inflictedStatuses": Object.keys(effectData) }; }; -StatusEffectsReceiver.prototype.InflictEffect = function(statusName, data) +StatusEffectsReceiver.prototype.AddStatus = function(statusName, data) { if (this.activeStatusEffects[statusName]) return; this.activeStatusEffects[statusName] = {}; let status = this.activeStatusEffects[statusName]; status.duration = +data.Duration; status.interval = +data.Interval; status.damage = +data.Damage; status.timeElapsed = 0; status.firstTime = true; let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); status.timer = cmpTimer.SetInterval(this.entity, IID_StatusEffectsReceiver, "ExecuteEffect", 0, +status.interval, statusName); }; -StatusEffectsReceiver.prototype.RemoveEffect = function(statusName) { +StatusEffectsReceiver.prototype.RemoveStatus = function(statusName) { if (!this.activeStatusEffects[statusName]) return; let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.CancelTimer(this.activeStatusEffects[statusName].timer); this.activeStatusEffects[statusName] = undefined; }; StatusEffectsReceiver.prototype.ExecuteEffect = function(statusName, lateness) { let status = this.activeStatusEffects[statusName]; if (!status) return; if (status.firstTime) { status.firstTime = false; status.timeElapsed += lateness; } else status.timeElapsed += status.interval + lateness; - let cmpDamage = Engine.QueryInterface(SYSTEM_ENTITY, IID_Damage); - - cmpDamage.CauseDamage({ - "strengths": { [statusName]: status.damage }, - "target": this.entity, - "attacker": -1, - "multiplier": 1, - "type": statusName, - "attackerOwner": -1 - }); + Attacking.HandleAttackEffects(statusName, { "Damage": { [statusName]: status.damage } }, this.entity, -1, -1); if (status.timeElapsed >= status.duration) - this.RemoveEffect(statusName); + this.RemoveStatus(statusName); }; Engine.RegisterComponentType(IID_StatusEffectsReceiver, "StatusEffectsReceiver", StatusEffectsReceiver); Index: ps/trunk/binaries/data/mods/public/simulation/components/UnitAI.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/UnitAI.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/UnitAI.js (revision 22754) @@ -1,6035 +1,6035 @@ function UnitAI() {} UnitAI.prototype.Schema = "Controls the unit's movement, attacks, etc, in response to commands from the player." + "" + "" + "" + "violent" + "aggressive" + "defensive" + "passive" + "standground" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "violent" + "aggressive" + "defensive" + "passive" + "skittish" + "domestic" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""+ "" + ""; // Unit stances. // There some targeting options: // targetVisibleEnemies: anything in vision range is a viable target // targetAttackersAlways: anything that hurts us is a viable target, // possibly overriding user orders! // There are some response options, triggered when targets are detected: // respondFlee: run away // respondChase: start chasing after the enemy // respondChaseBeyondVision: start chasing, and don't stop even if it's out // of this unit's vision range (though still visible to the player) // respondStandGround: attack enemy but don't move at all // respondHoldGround: attack enemy but don't move far from current position // TODO: maybe add targetAggressiveEnemies (don't worry about lone scouts, // do worry around armies slaughtering the guy standing next to you), etc. var g_Stances = { "violent": { "targetVisibleEnemies": true, "targetAttackersAlways": true, "respondFlee": false, "respondChase": true, "respondChaseBeyondVision": true, "respondStandGround": false, "respondHoldGround": false, "selectable": true }, "aggressive": { "targetVisibleEnemies": true, "targetAttackersAlways": false, "respondFlee": false, "respondChase": true, "respondChaseBeyondVision": false, "respondStandGround": false, "respondHoldGround": false, "selectable": true }, "defensive": { "targetVisibleEnemies": true, "targetAttackersAlways": false, "respondFlee": false, "respondChase": false, "respondChaseBeyondVision": false, "respondStandGround": false, "respondHoldGround": true, "selectable": true }, "passive": { "targetVisibleEnemies": false, "targetAttackersAlways": false, "respondFlee": true, "respondChase": false, "respondChaseBeyondVision": false, "respondStandGround": false, "respondHoldGround": false, "selectable": true }, "standground": { "targetVisibleEnemies": true, "targetAttackersAlways": false, "respondFlee": false, "respondChase": false, "respondChaseBeyondVision": false, "respondStandGround": true, "respondHoldGround": false, "selectable": true }, "none": { // Only to be used by AI or trigger scripts "targetVisibleEnemies": false, "targetAttackersAlways": false, "respondFlee": false, "respondChase": false, "respondChaseBeyondVision": false, "respondStandGround": false, "respondHoldGround": false, "selectable": false } }; // See ../helpers/FSM.js for some documentation of this FSM specification syntax UnitAI.prototype.UnitFsmSpec = { // Default event handlers: "MovementUpdate": function(msg) { // ignore spurious movement messages // (these can happen when stopping moving at the same time // as switching states) }, "ConstructionFinished": function(msg) { // ignore uninteresting construction messages }, "LosRangeUpdate": function(msg) { // ignore newly-seen units by default }, "LosHealRangeUpdate": function(msg) { // ignore newly-seen injured units by default }, "Attacked": function(msg) { // ignore attacker }, "HealthChanged": function(msg) { // ignore }, "PackFinished": function(msg) { // ignore }, "PickupCanceled": function(msg) { // ignore }, "TradingCanceled": function(msg) { // ignore }, "GuardedAttacked": function(msg) { // ignore }, // Formation handlers: "FormationLeave": function(msg) { // ignore when we're not in FORMATIONMEMBER }, // Called when being told to walk as part of a formation "Order.FormationWalk": function(msg) { // Let players move captured domestic animals around if (this.IsAnimal() && !this.IsDomestic() || this.IsTurret()) { this.FinishOrder(); return; } // For packable units: // 1. If packed, we can move. // 2. If unpacked, we first need to pack, then follow case 1. if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } this.SetNextState("FORMATIONMEMBER.WALKING"); }, // Special orders: // (these will be overridden by various states) "Order.LeaveFoundation": function(msg) { // If foundation is not ally of entity, or if entity is unpacked siege, // ignore the order if (!IsOwnedByAllyOfEntity(this.entity, msg.data.target) && !Engine.QueryInterface(SYSTEM_ENTITY, IID_CeasefireManager).IsCeasefireActive() || this.IsPacking() || this.CanPack() || this.IsTurret()) { this.FinishOrder(); return; } // Move a tile outside the building if necessary. let range = 4; if (this.CheckTargetRangeExplicit(msg.data.target, range, -1)) this.FinishOrder(); else { this.order.data.min = range; this.SetNextState("INDIVIDUAL.WALKING"); } }, // Individual orders: // (these will switch the unit out of formation mode) "Order.Stop": function(msg) { // We have no control over non-domestic animals. if (this.IsAnimal() && !this.IsDomestic()) { this.FinishOrder(); return; } // Stop moving immediately. this.StopMoving(); this.FinishOrder(); // No orders left, we're an individual now if (this.IsAnimal()) this.SetNextState("ANIMAL.IDLE"); else this.SetNextState("INDIVIDUAL.IDLE"); }, "Order.Walk": function(msg) { // Let players move captured domestic animals around if (this.IsAnimal() && !this.IsDomestic() || this.IsTurret()) { this.FinishOrder(); return; } // For packable units: // 1. If packed, we can move. // 2. If unpacked, we first need to pack, then follow case 1. if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } this.SetHeldPosition(this.order.data.x, this.order.data.z); // It's not too bad if we don't arrive at exactly the right position. this.order.data.relaxed = true; if (this.IsAnimal()) this.SetNextState("ANIMAL.WALKING"); else this.SetNextState("INDIVIDUAL.WALKING"); }, "Order.WalkAndFight": function(msg) { // Let players move captured domestic animals around if (this.IsAnimal() && !this.IsDomestic() || this.IsTurret()) { this.FinishOrder(); return; } // For packable units: // 1. If packed, we can move. // 2. If unpacked, we first need to pack, then follow case 1. if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } this.SetHeldPosition(this.order.data.x, this.order.data.z); // It's not too bad if we don't arrive at exactly the right position. this.order.data.relaxed = true; if (this.IsAnimal()) this.SetNextState("ANIMAL.WALKING"); // WalkAndFight not applicable for animals else this.SetNextState("INDIVIDUAL.WALKINGANDFIGHTING"); }, "Order.WalkToTarget": function(msg) { // Let players move captured domestic animals around if (this.IsAnimal() && !this.IsDomestic() || this.IsTurret()) { this.FinishOrder(); return; } // For packable units: // 1. If packed, we can move. // 2. If unpacked, we first need to pack, then follow case 1. if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } if (this.CheckRange(this.order.data)) { // We are already at the target, or can't move at all this.FinishOrder(); return true; } // It's not too bad if we don't arrive at exactly the right position. this.order.data.relaxed = true; if (this.IsAnimal()) this.SetNextState("ANIMAL.WALKING"); else this.SetNextState("INDIVIDUAL.WALKING"); }, "Order.PickupUnit": function(msg) { let cmpGarrisonHolder = Engine.QueryInterface(this.entity, IID_GarrisonHolder); if (!cmpGarrisonHolder || cmpGarrisonHolder.IsFull()) { this.FinishOrder(); return; } if (this.CheckRange(this.order.data)) { this.FinishOrder(); return; } // Check if we need to move // TODO implement a better way to know if we are on the shoreline let needToMove = true; let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (this.lastShorelinePosition && cmpPosition && (this.lastShorelinePosition.x == cmpPosition.GetPosition().x) && (this.lastShorelinePosition.z == cmpPosition.GetPosition().z)) // we were already on the shoreline, and have not moved since if (DistanceBetweenEntities(this.entity, this.order.data.target) < 50) needToMove = false; if (needToMove) this.SetNextState("INDIVIDUAL.PICKUP.APPROACHING"); else this.SetNextState("INDIVIDUAL.PICKUP.LOADING"); }, "Order.Guard": function(msg) { if (!this.AddGuard(this.order.data.target)) { this.FinishOrder(); return; } if (!this.CheckTargetRangeExplicit(this.isGuardOf, 0, this.guardRange)) this.SetNextState("INDIVIDUAL.GUARD.ESCORTING"); else this.SetNextState("INDIVIDUAL.GUARD.GUARDING"); }, "Order.Flee": function(msg) { if (this.IsAnimal()) this.SetNextState("ANIMAL.FLEEING"); else this.SetNextState("INDIVIDUAL.FLEEING"); }, "Order.Attack": function(msg) { // Check the target is alive if (!this.TargetIsAlive(this.order.data.target)) { this.FinishOrder(); return; } // Work out how to attack the given target var type = this.GetBestAttackAgainst(this.order.data.target, this.order.data.allowCapture); if (!type) { // Oops, we can't attack at all this.FinishOrder(); return; } this.order.data.attackType = type; this.RememberTargetPosition(); if (this.order.data.hunting && this.orderQueue.length > 1 && this.orderQueue[1].type === "Gather") this.RememberTargetPosition(this.orderQueue[1].data); // If we are already at the target, try attacking it from here if (this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType)) { // For packable units within attack range: // 1. If unpacked, we can attack the target. // 2. If packed, we first need to unpack, then follow case 1. if (this.CanUnpack()) { this.PushOrderFront("Unpack", { "force": true }); return; } if (this.IsAnimal()) this.SetNextState("ANIMAL.COMBAT.ATTACKING"); else this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING"); return; } // If we can't reach the target, but are standing ground, then abandon this attack order. // Unless we're hunting, that's a special case where we should continue attacking our target. if (this.GetStance().respondStandGround && !this.order.data.force && !this.order.data.hunting || this.IsTurret()) { this.FinishOrder(); return; } // For packable units out of attack range: // 1. If packed, we need to move to attack range and then unpack. // 2. If unpacked, we first need to pack, then follow case 1. if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } if (this.IsAnimal()) this.SetNextState("ANIMAL.COMBAT.APPROACHING"); else this.SetNextState("INDIVIDUAL.COMBAT.APPROACHING"); }, "Order.Patrol": function(msg) { if (this.IsAnimal() || this.IsTurret()) { this.FinishOrder(); return; } if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } // It's not too bad if we don't arrive at exactly the right position. this.order.data.relaxed = true; this.SetNextState("INDIVIDUAL.PATROL"); }, "Order.Heal": function(msg) { // Check the target is alive if (!this.TargetIsAlive(this.order.data.target)) { this.FinishOrder(); return; } // Healers can't heal themselves. if (this.order.data.target == this.entity) { this.FinishOrder(); return; } // Check if the target is in range if (this.CheckTargetRange(this.order.data.target, IID_Heal)) { this.SetNextState("INDIVIDUAL.HEAL.HEALING"); return; } // If we can't reach the target, but are standing ground, // then abandon this heal order if (this.GetStance().respondStandGround && !this.order.data.force) { this.FinishOrder(); return; } this.SetNextState("INDIVIDUAL.HEAL.APPROACHING"); }, "Order.Gather": function(msg) { // If the target is still alive, we need to kill it first if (this.MustKillGatherTarget(this.order.data.target)) { // Make sure we can attack the target, else we'll get very stuck if (!this.GetBestAttackAgainst(this.order.data.target, false)) { // Oops, we can't attack at all - give up // TODO: should do something so the player knows why this failed this.FinishOrder(); return; } // The target was visible when this order was issued, // but could now be invisible again. if (!this.CheckTargetVisible(this.order.data.target)) { if (this.order.data.secondTry === undefined) { this.order.data.secondTry = true; this.PushOrderFront("Walk", this.order.data.lastPos); } // We couldn't move there, or the target moved away else { let data = this.order.data; if (!this.FinishOrder()) this.PushOrderFront("GatherNearPosition", { "x": data.lastPos.x, "z": data.lastPos.z, "type": data.type, "template": data.template }); } return; } this.PushOrderFront("Attack", { "target": this.order.data.target, "force": !!this.order.data.force, "hunting": true, "allowCapture": false }); return; } this.RememberTargetPosition(); if (!this.order.data.initPos) this.order.data.initPos = this.order.data.lastPos; if (this.CheckTargetRange(this.order.data.target, IID_ResourceGatherer)) this.SetNextState("INDIVIDUAL.GATHER.GATHERING"); else this.SetNextState("INDIVIDUAL.GATHER.APPROACHING"); }, "Order.GatherNearPosition": function(msg) { this.SetNextState("INDIVIDUAL.GATHER.WALKING"); this.order.data.initPos = { 'x': this.order.data.x, 'z': this.order.data.z }; this.order.data.relaxed = true; }, "Order.ReturnResource": function(msg) { // Check if the dropsite is already in range if (this.CheckTargetRange(this.order.data.target, IID_ResourceGatherer) && this.CanReturnResource(this.order.data.target, true)) { var cmpResourceDropsite = Engine.QueryInterface(this.order.data.target, IID_ResourceDropsite); if (cmpResourceDropsite) { // Dump any resources we can var dropsiteTypes = cmpResourceDropsite.GetTypes(); Engine.QueryInterface(this.entity, IID_ResourceGatherer).CommitResources(dropsiteTypes); // Stop showing the carried resource animation. this.SetDefaultAnimationVariant(); // Our next order should always be a Gather, // so just switch back to that order this.FinishOrder(); return; } } this.SetNextState("INDIVIDUAL.RETURNRESOURCE.APPROACHING"); }, "Order.Trade": function(msg) { // We must check if this trader has both markets in case it was a back-to-work order var cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); if (!cmpTrader || !cmpTrader.HasBothMarkets()) { this.FinishOrder(); return; } // TODO find the nearest way-point from our position, and start with it this.waypoints = undefined; this.SetNextState("TRADE.APPROACHINGMARKET"); }, "Order.Repair": function(msg) { // Try to move within range if (this.CheckTargetRange(this.order.data.target, IID_Builder)) this.SetNextState("INDIVIDUAL.REPAIR.REPAIRING"); else this.SetNextState("INDIVIDUAL.REPAIR.APPROACHING"); }, "Order.Garrison": function(msg) { if (this.IsTurret()) { this.SetNextState("IDLE"); return; } else if (this.IsGarrisoned()) { this.SetNextState("INDIVIDUAL.GARRISON.GARRISONED"); return; } // For packable units: // 1. If packed, we can move to the garrison target. // 2. If unpacked, we first need to pack, then follow case 1. if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } this.SetNextState("INDIVIDUAL.GARRISON.APPROACHING"); }, "Order.Ungarrison": function() { this.FinishOrder(); this.isGarrisoned = false; }, "Order.Cheering": function(msg) { this.SetNextState("INDIVIDUAL.CHEERING"); }, "Order.Pack": function(msg) { if (this.CanPack()) this.SetNextState("INDIVIDUAL.PACKING"); }, "Order.Unpack": function(msg) { if (this.CanUnpack()) this.SetNextState("INDIVIDUAL.UNPACKING"); }, "Order.CancelPack": function(msg) { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); if (cmpPack && cmpPack.IsPacking() && !cmpPack.IsPacked()) cmpPack.CancelPack(); this.FinishOrder(); }, "Order.CancelUnpack": function(msg) { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); if (cmpPack && cmpPack.IsPacking() && cmpPack.IsPacked()) cmpPack.CancelPack(); this.FinishOrder(); }, // States for the special entity representing a group of units moving in formation: "FORMATIONCONTROLLER": { "Order.Walk": function(msg) { this.CallMemberFunction("SetHeldPosition", [msg.data.x, msg.data.z]); this.SetNextState("WALKING"); }, "Order.WalkAndFight": function(msg) { this.CallMemberFunction("SetHeldPosition", [msg.data.x, msg.data.z]); this.SetNextState("WALKINGANDFIGHTING"); }, "Order.MoveIntoFormation": function(msg) { this.CallMemberFunction("SetHeldPosition", [msg.data.x, msg.data.z]); this.SetNextState("FORMING"); }, // Only used by other orders to walk there in formation "Order.WalkToTargetRange": function(msg) { if (!this.CheckRange(this.order.data)) this.SetNextState("WALKING"); else this.FinishOrder(); }, "Order.WalkToTarget": function(msg) { if (!this.CheckRange(this.order.data)) this.SetNextState("WALKING"); else this.FinishOrder(); }, "Order.WalkToPointRange": function(msg) { if (!this.CheckRange(this.order.data)) this.SetNextState("WALKING"); else this.FinishOrder(); }, "Order.Patrol": function(msg) { this.CallMemberFunction("SetHeldPosition", [msg.data.x, msg.data.z]); this.SetNextState("PATROL"); }, "Order.Guard": function(msg) { this.CallMemberFunction("Guard", [msg.data.target, false]); var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.Disband(); }, "Order.Stop": function(msg) { if (!this.IsAttackingAsFormation()) this.CallMemberFunction("Stop", [false]); this.StopMoving(); this.FinishOrder(); }, "Order.Attack": function(msg) { var target = msg.data.target; var allowCapture = msg.data.allowCapture; var cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI); if (cmpTargetUnitAI && cmpTargetUnitAI.IsFormationMember()) target = cmpTargetUnitAI.GetFormationController(); var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); // Check if we are already in range, otherwise walk there if (!this.CheckTargetAttackRange(target, target)) { if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { this.SetNextState("COMBAT.APPROACHING"); return; } this.FinishOrder(); return; } this.CallMemberFunction("Attack", [target, allowCapture, false]); if (cmpAttack.CanAttackAsFormation()) this.SetNextState("COMBAT.ATTACKING"); else this.SetNextState("MEMBER"); }, "Order.Garrison": function(msg) { if (!Engine.QueryInterface(msg.data.target, IID_GarrisonHolder)) { this.FinishOrder(); return; } // Check if we are already in range, otherwise walk there if (!this.CheckGarrisonRange(msg.data.target)) { if (!this.CheckTargetVisible(msg.data.target)) { this.FinishOrder(); return; } else { this.SetNextState("GARRISON.APPROACHING"); return; } } this.SetNextState("GARRISON.GARRISONING"); }, "Order.Gather": function(msg) { if (this.MustKillGatherTarget(msg.data.target)) { // The target was visible when this order was given, // but could now be invisible. if (!this.CheckTargetVisible(msg.data.target)) { if (msg.data.secondTry === undefined) { msg.data.secondTry = true; this.PushOrderFront("Walk", msg.data.lastPos); } // We couldn't move there, or the target moved away else { let data = msg.data; if (!this.FinishOrder()) this.PushOrderFront("GatherNearPosition", { "x": data.lastPos.x, "z": data.lastPos.z, "type": data.type, "template": data.template }); } return; } this.PushOrderFront("Attack", { "target": msg.data.target, "force": !!msg.data.force, "hunting": true, "allowCapture": false, "min": 0, "max": 10 }); return; } // TODO: on what should we base this range? // Check if we are already in range, otherwise walk there if (!this.CheckTargetRangeExplicit(msg.data.target, 0, 10)) { if (!this.CanGather(msg.data.target) || !this.CheckTargetVisible(msg.data.target)) // The target isn't gatherable or not visible any more. this.FinishOrder(); // TODO: Should we issue a gather-near-position order // if the target isn't gatherable/doesn't exist anymore? else // Out of range; move there in formation this.PushOrderFront("WalkToTargetRange", { "target": msg.data.target, "min": 0, "max": 10 }); return; } this.CallMemberFunction("Gather", [msg.data.target, false]); this.SetNextState("MEMBER"); }, "Order.GatherNearPosition": function(msg) { // TODO: on what should we base this range? // Check if we are already in range, otherwise walk there if (!this.CheckPointRangeExplicit(msg.data.x, msg.data.z, 0, 20)) { // Out of range; move there in formation this.PushOrderFront("WalkToPointRange", { "x": msg.data.x, "z": msg.data.z, "min": 0, "max": 20 }); return; } this.CallMemberFunction("GatherNearPosition", [msg.data.x, msg.data.z, msg.data.type, msg.data.template, false]); this.SetNextState("MEMBER"); }, "Order.Heal": function(msg) { // TODO: on what should we base this range? // Check if we are already in range, otherwise walk there if (!this.CheckTargetRangeExplicit(msg.data.target, 0, 10)) { if (!this.TargetIsAlive(msg.data.target) || !this.CheckTargetVisible(msg.data.target)) // The target was destroyed this.FinishOrder(); else // Out of range; move there in formation this.PushOrderFront("WalkToTargetRange", { "target": msg.data.target, "min": 0, "max": 10 }); return; } this.CallMemberFunction("Heal", [msg.data.target, false]); this.SetNextState("MEMBER"); }, "Order.Repair": function(msg) { // TODO: on what should we base this range? // Check if we are already in range, otherwise walk there if (!this.CheckTargetRangeExplicit(msg.data.target, 0, 10)) { if (!this.TargetIsAlive(msg.data.target) || !this.CheckTargetVisible(msg.data.target)) // The building was finished or destroyed this.FinishOrder(); else // Out of range move there in formation this.PushOrderFront("WalkToTargetRange", { "target": msg.data.target, "min": 0, "max": 10 }); return; } this.CallMemberFunction("Repair", [msg.data.target, msg.data.autocontinue, false]); this.SetNextState("MEMBER"); }, "Order.ReturnResource": function(msg) { // TODO: on what should we base this range? // Check if we are already in range, otherwise walk there if (!this.CheckTargetRangeExplicit(msg.data.target, 0, 10)) { if (!this.TargetIsAlive(msg.data.target) || !this.CheckTargetVisible(msg.data.target)) // The target was destroyed this.FinishOrder(); else // Out of range; move there in formation this.PushOrderFront("WalkToTargetRange", { "target": msg.data.target, "min": 0, "max": 10 }); return; } this.CallMemberFunction("ReturnResource", [msg.data.target, false]); this.SetNextState("MEMBER"); }, "Order.Pack": function(msg) { this.CallMemberFunction("Pack", [false]); this.SetNextState("MEMBER"); }, "Order.Unpack": function(msg) { this.CallMemberFunction("Unpack", [false]); this.SetNextState("MEMBER"); }, "IDLE": { "enter": function(msg) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(false); }, }, "WALKING": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || this.CheckRange(this.order.data)) { this.FinishOrder(); this.CallMemberFunction("ResetFinishOrder", []); } }, }, "WALKINGANDFIGHTING": { "enter": function(msg) { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } this.StartTimer(0, 1000); let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); }, "leave": function() { this.StopMoving(); this.StopTimer(); }, "Timer": function(msg) { // check if there are no enemies to attack this.FindWalkAndFightTargets(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || this.CheckRange(this.order.data)) { this.FinishOrder(); this.CallMemberFunction("ResetFinishOrder", []); } }, }, "PATROL": { "enter": function(msg) { // Memorize the origin position in case that we want to go back let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) { this.FinishOrder(); return; } if (!this.patrolStartPosOrder) { this.patrolStartPosOrder = cmpPosition.GetPosition(); this.patrolStartPosOrder.targetClasses = this.order.data.targetClasses; this.patrolStartPosOrder.allowCapture = this.order.data.allowCapture; } if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } this.StartTimer(0, 1000); let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); }, "Timer": function(msg) { // Check if there are no enemies to attack this.FindWalkAndFightTargets(); }, "leave": function(msg) { this.StopTimer(); this.StopMoving(); delete this.patrolStartPosOrder; }, "MovementUpdate": function(msg) { if (!msg.likelyFailure && !this.CheckRange(this.order.data)) return; /** * A-B-A-B-..: * if the user only commands one patrol order, the patrol will be between * the last position and the defined waypoint * A-B-C-..-A-B-..: * otherwise, the patrol is only between the given patrol commands and the * last position is not included (last position = the position where the unit * is located at the time of the first patrol order) */ if (this.orderQueue.length == 1) this.PushOrder("Patrol", this.patrolStartPosOrder); this.PushOrder(this.order.type, this.order.data); this.FinishOrder(); }, }, "GARRISON":{ "enter": function() { // If the garrisonholder should pickup, warn it so it can take needed action var cmpGarrisonHolder = Engine.QueryInterface(this.order.data.target, IID_GarrisonHolder); if (cmpGarrisonHolder && cmpGarrisonHolder.CanPickup(this.entity)) { this.pickup = this.order.data.target; // temporary, deleted in "leave" Engine.PostMessage(this.pickup, MT_PickupRequested, { "entity": this.entity }); } }, "leave": function() { // If a pickup has been requested and not yet canceled, cancel it if (this.pickup) { Engine.PostMessage(this.pickup, MT_PickupCanceled, { "entity": this.entity }); delete this.pickup; } }, "APPROACHING": { "enter": function() { if (!this.MoveToGarrisonRange(this.order.data.target)) { this.FinishOrder(); return true; } let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || msg.likelySuccess) this.SetNextState("GARRISONING"); }, }, "GARRISONING": { "enter": function() { // If a pickup has been requested, cancel it as it will be requested by members if (this.pickup) { Engine.PostMessage(this.pickup, MT_PickupCanceled, { "entity": this.entity }); delete this.pickup; } this.CallMemberFunction("Garrison", [this.order.data.target, false]); this.SetNextState("MEMBER"); }, }, }, "FORMING": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (!msg.likelyFailure && !this.CheckRange(this.order.data)) return; if (this.FinishOrder()) { this.CallMemberFunction("ResetFinishOrder", []); return; } var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.FindInPosition(); } }, "COMBAT": { "APPROACHING": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); this.CallMemberFunction("Attack", [this.order.data.target, this.order.data.allowCapture, false]); if (cmpAttack.CanAttackAsFormation()) this.SetNextState("COMBAT.ATTACKING"); else this.SetNextState("MEMBER"); }, }, "ATTACKING": { // Wait for individual members to finish "enter": function(msg) { var target = this.order.data.target; var allowCapture = this.order.data.allowCapture; // Check if we are already in range, otherwise walk there if (!this.CheckTargetAttackRange(target, target)) { if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { this.FinishOrder(); this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); return true; } this.FinishOrder(); return true; } var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); // TODO fix the rearranging while attacking as formation cmpFormation.SetRearrange(!this.IsAttackingAsFormation()); cmpFormation.MoveMembersIntoFormation(false, false); this.StartTimer(200, 200); return false; }, "Timer": function(msg) { var target = this.order.data.target; var allowCapture = this.order.data.allowCapture; // Check if we are already in range, otherwise walk there if (!this.CheckTargetAttackRange(target, target)) { if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { this.FinishOrder(); this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); return; } this.FinishOrder(); return; } }, "leave": function(msg) { this.StopTimer(); var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (cmpFormation) cmpFormation.SetRearrange(true); }, }, }, "MEMBER": { // Wait for individual members to finish "enter": function(msg) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(false); this.StopMoving(); this.StartTimer(1000, 1000); }, "Timer": function(msg) { // Have all members finished the task? if (!this.TestAllMemberFunction("HasFinishedOrder", [])) return; this.CallMemberFunction("ResetFinishOrder", []); // Execute the next order if (this.FinishOrder()) { // if WalkAndFight order, look for new target before moving again if (this.IsWalkingAndFighting()) this.FindWalkAndFightTargets(); return; } }, "leave": function(msg) { this.StopTimer(); let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.MoveToMembersCenter(); }, }, }, // States for entities moving as part of a formation: "FORMATIONMEMBER": { "FormationLeave": function(msg) { // We're not in a formation anymore, so no need to track this. this.finishedOrder = false; // Stop moving as soon as the formation disbands this.StopMoving(); // If the controller handled an order but some members rejected it, // they will have no orders and be in the FORMATIONMEMBER.IDLE state. if (this.orderQueue.length) { // We're leaving the formation, so stop our FormationWalk order if (this.FinishOrder()) return; } // No orders left, we're an individual now this.SetNextState("INDIVIDUAL.IDLE"); }, // Override the LeaveFoundation order since we're not doing // anything more important (and we might be stuck in the WALKING // state forever and need to get out of foundations in that case) "Order.LeaveFoundation": function(msg) { // If foundation is not ally of entity, or if entity is unpacked siege, // ignore the order if (!IsOwnedByAllyOfEntity(this.entity, msg.data.target) && !Engine.QueryInterface(SYSTEM_ENTITY, IID_CeasefireManager).IsCeasefireActive() || this.IsPacking() || this.CanPack() || this.IsTurret()) { this.FinishOrder(); return; } // Move a tile outside the building let range = 4; if (this.CheckTargetRangeExplicit(msg.data.target, range, -1)) { // We are already at the target, or can't move at all this.FinishOrder(); } else { this.order.data.min = range; this.SetNextState("WALKINGTOPOINT"); } }, "enter": function() { if (this.IsAnimal()) { // Animals can't go in formation. warn("Entity " + this.entity + " was put in FORMATIONMEMBER state but is an animal"); this.FinishOrder(); this.SetNextState("ANIMAL.IDLE"); return true; } let cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) this.SetAnimationVariant(cmpFormation.GetFormationAnimation(this.entity)); }, "leave": function() { this.SetDefaultAnimationVariant(); }, "IDLE": "INDIVIDUAL.IDLE", "WALKING": { "enter": function() { this.formationOffset = { "x": this.order.data.x, "z": this.order.data.z }; let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); cmpUnitMotion.MoveToFormationOffset(this.order.data.target, this.order.data.x, this.order.data.z); }, "leave": function() { this.StopMoving(); }, // Occurs when the unit has reached its destination and the controller // is done moving. The controller is notified. "MovementUpdate": function(msg) { // We can only finish this order if the move was really completed. let cmpPosition = Engine.QueryInterface(this.formationController, IID_Position); let atDestination = cmpPosition && cmpPosition.IsInWorld(); if (!atDestination && cmpPosition) { let pos = cmpPosition.GetPosition2D(); atDestination = this.CheckPointRangeExplicit(pos.X + this.order.data.x, pos.Y + this.order.data.z, 0, 1); } if (!atDestination && !msg.likelyFailure) return; if (this.FinishOrder()) return; let cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) cmpFormation.SetInPosition(this.entity); }, }, // Special case used by Order.LeaveFoundation "WALKINGTOPOINT": { "enter": function() { var cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) cmpFormation.UnsetInPosition(this.entity); if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } }, "MovementUpdate": function() { if (!this.CheckRange(this.order.data)) return; this.StopMoving(); this.FinishOrder(); }, }, }, // States for entities not part of a formation: "INDIVIDUAL": { "enter": function() { // Sanity-checking if (this.IsAnimal()) error("Animal got moved into INDIVIDUAL.* state"); }, "Attacked": function(msg) { // Respond to attack if we always target attackers or during unforced orders if (this.GetStance().targetAttackersAlways || !this.order || !this.order.data || !this.order.data.force) this.RespondToTargetedEntities([msg.data.attacker]); }, "GuardedAttacked": function(msg) { // do nothing if we have a forced order in queue before the guard order for (var i = 0; i < this.orderQueue.length; ++i) { if (this.orderQueue[i].type == "Guard") break; if (this.orderQueue[i].data && this.orderQueue[i].data.force) return; } // if we already are targeting another unit still alive, finish with it first if (this.order && (this.order.type == "WalkAndFight" || this.order.type == "Attack")) if (this.order.data.target != msg.data.attacker && this.TargetIsAlive(msg.data.attacker)) return; var cmpIdentity = Engine.QueryInterface(this.entity, IID_Identity); var cmpHealth = Engine.QueryInterface(this.isGuardOf, IID_Health); if (cmpIdentity && cmpIdentity.HasClass("Support") && cmpHealth && cmpHealth.IsInjured()) { if (this.CanHeal(this.isGuardOf)) this.PushOrderFront("Heal", { "target": this.isGuardOf, "force": false }); else if (this.CanRepair(this.isGuardOf)) this.PushOrderFront("Repair", { "target": this.isGuardOf, "autocontinue": false, "force": false }); return; } // if the attacker is a building and we can repair the guarded, repair it rather than attacking var cmpBuildingAI = Engine.QueryInterface(msg.data.attacker, IID_BuildingAI); if (cmpBuildingAI && this.CanRepair(this.isGuardOf)) { this.PushOrderFront("Repair", { "target": this.isGuardOf, "autocontinue": false, "force": false }); return; } // target the unit if (this.CheckTargetVisible(msg.data.attacker)) this.PushOrderFront("Attack", { "target": msg.data.attacker, "force": false, "allowCapture": true }); else { var cmpPosition = Engine.QueryInterface(msg.data.attacker, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return; var pos = cmpPosition.GetPosition(); this.PushOrderFront("WalkAndFight", { "x": pos.x, "z": pos.z, "target": msg.data.attacker, "force": false }); // if we already had a WalkAndFight, keep only the most recent one in case the target has moved if (this.orderQueue[1] && this.orderQueue[1].type == "WalkAndFight") { this.orderQueue.splice(1, 1); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); } } }, "IDLE": { "enter": function() { // Switch back to idle animation to guarantee we won't // get stuck with an incorrect animation this.SelectAnimation("idle"); // Idle is the default state. If units try, from the IDLE.enter sub-state, to // begin another order, and that order fails (calling FinishOrder), they might // end up in an infinite loop. To avoid this, all methods that could put the unit in // a new state are done on the next turn. // This wastes a turn but avoids infinite loops. // Further, the GUI and AI want to know when a unit is idle, // but sending this info in Idle.enter will send spurious messages. // Pick 100 to execute on the next turn in SP and MP. this.StartTimer(100); }, "leave": function() { var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losRangeQuery) cmpRangeManager.DisableActiveQuery(this.losRangeQuery); if (this.losHealRangeQuery) cmpRangeManager.DisableActiveQuery(this.losHealRangeQuery); this.StopTimer(); if (this.isIdle) { this.isIdle = false; Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle }); } }, "LosRangeUpdate": function(msg) { if (this.GetStance().targetVisibleEnemies) { // Start attacking one of the newly-seen enemy (if any) this.AttackEntitiesByPreference(msg.data.added); } }, "LosHealRangeUpdate": function(msg) { this.RespondToHealableEntities(msg.data.added); }, "Timer": function(msg) { // If the unit is guarding/escorting, go back to its duty if (this.isGuardOf) { this.Guard(this.isGuardOf, false); return; } // If a unit can heal and attack we first want to heal wounded units, // so check if we are a healer and find whether there's anybody nearby to heal. // (If anyone approaches later it'll be handled via LosHealRangeUpdate.) // If anyone in sight gets hurt that will be handled via LosHealRangeUpdate. if (this.IsHealer() && this.FindNewHealTargets()) return; // (abort the FSM transition since we may have already switched state) // If we entered the idle state we must have nothing better to do, // so immediately check whether there's anybody nearby to attack. // (If anyone approaches later, it'll be handled via LosRangeUpdate.) if (this.FindNewTargets()) return; // (abort the FSM transition since we may have already switched state) if (this.formationOffset && this.formationController) { this.PushOrder("FormationWalk", { "target": this.formationController, "x": this.formationOffset.x, "z": this.formationOffset.z, }); return; } if (!this.isIdle) { this.isIdle = true; Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle }); } }, }, "WALKING": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { // If it looks like the path is failing, and we are close enough (3 tiles) // stop anyways. This avoids pathing for an unreachable goal and reduces lag considerably. if (msg.likelyFailure || msg.obstructed && this.RelaxedMaxRangeCheck(this.order.data, this.DefaultRelaxedMaxRange) || this.CheckRange(this.order.data)) this.FinishOrder(); }, }, "WALKINGANDFIGHTING": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } // Show weapons rather than carried resources. this.SetAnimationVariant("combat"); this.StartTimer(0, 1000); }, "Timer": function(msg) { this.FindWalkAndFightTargets(); }, "leave": function(msg) { this.StopMoving(); this.StopTimer(); this.SetDefaultAnimationVariant(); }, "MovementUpdate": function(msg) { // If it looks like the path is failing, and we are close enough (3 tiles) // stop anyways. This avoids pathing for an unreachable goal and reduces lag considerably. if (msg.likelyFailure || msg.obstructed && this.RelaxedMaxRangeCheck(this.order.data, this.DefaultRelaxedMaxRange) || this.CheckRange(this.order.data)) this.FinishOrder(); }, }, "PATROL": { "enter": function() { // Memorize the origin position in case that we want to go back let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld() || !this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } if (!this.patrolStartPosOrder) { this.patrolStartPosOrder = cmpPosition.GetPosition(); this.patrolStartPosOrder.targetClasses = this.order.data.targetClasses; this.patrolStartPosOrder.allowCapture = this.order.data.allowCapture; } this.StartTimer(0, 1000); this.SetAnimationVariant("combat"); }, "leave": function() { this.StopMoving(); this.StopTimer(); delete this.patrolStartPosOrder; this.SetDefaultAnimationVariant(); }, "Timer": function(msg) { this.FindWalkAndFightTargets(); }, "MovementUpdate": function(msg) { if (!msg.likelyFailure && !msg.likelySuccess && !this.RelaxedMaxRangeCheck(this.order.data, this.DefaultRelaxedMaxRange)) return; if (this.orderQueue.length == 1) this.PushOrder("Patrol", this.patrolStartPosOrder); this.PushOrder(this.order.type, this.order.data); this.FinishOrder(); }, }, "GUARD": { "RemoveGuard": function() { this.StopMoving(); this.FinishOrder(); }, "ESCORTING": { "enter": function() { if (!this.MoveToTargetRangeExplicit(this.isGuardOf, 0, this.guardRange)) { this.FinishOrder(); return true; } // Show weapons rather than carried resources. this.SetAnimationVariant("combat"); this.StartTimer(0, 1000); this.SetHeldPositionOnEntity(this.isGuardOf); return false; }, "Timer": function(msg) { // Check the target is alive if (!this.TargetIsAlive(this.isGuardOf)) { this.FinishOrder(); return; } // Adapt the speed to the one of the target if needed let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); if (cmpObstructionManager.IsInTargetRange(this.entity, this.isGuardOf, 0, 3 * this.guardRange, false)) { let cmpUnitAI = Engine.QueryInterface(this.isGuardOf, IID_UnitAI); if (cmpUnitAI) { let speed = cmpUnitAI.GetWalkSpeed(); if (speed < this.GetWalkSpeed()) this.SetSpeedMultiplier(speed / this.GetWalkSpeed()); } } this.SetHeldPositionOnEntity(this.isGuardOf); }, "leave": function(msg) { this.StopMoving(); this.ResetSpeedMultiplier(); this.StopTimer(); this.SetDefaultAnimationVariant(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || this.CheckTargetRangeExplicit(this.isGuardOf, 0, this.guardRange)) this.SetNextState("GUARDING"); }, }, "GUARDING": { "enter": function() { this.StopMoving(); this.StartTimer(1000, 1000); this.SetHeldPositionOnEntity(this.entity); this.SetAnimationVariant("combat"); this.FaceTowardsTarget(this.order.data.target); return false; }, "LosRangeUpdate": function(msg) { // Start attacking one of the newly-seen enemy (if any) if (this.GetStance().targetVisibleEnemies) this.AttackEntitiesByPreference(msg.data.added); }, "Timer": function(msg) { // Check the target is alive if (!this.TargetIsAlive(this.isGuardOf)) { this.FinishOrder(); return; } // Then check is the target has moved and try following it. // TODO: find out what to do if we cannot move. if (!this.CheckTargetRangeExplicit(this.isGuardOf, 0, this.guardRange) && this.MoveToTargetRangeExplicit(this.isGuardOf, 0, this.guardRange)) this.SetNextState("ESCORTING"); else { this.FaceTowardsTarget(this.order.data.target); // if nothing better to do, check if the guarded needs to be healed or repaired var cmpHealth = Engine.QueryInterface(this.isGuardOf, IID_Health); if (cmpHealth && cmpHealth.IsInjured()) { if (this.CanHeal(this.isGuardOf)) this.PushOrderFront("Heal", { "target": this.isGuardOf, "force": false }); else if (this.CanRepair(this.isGuardOf)) this.PushOrderFront("Repair", { "target": this.isGuardOf, "autocontinue": false, "force": false }); } } }, "leave": function(msg) { this.StopTimer(); this.SetDefaultAnimationVariant(); }, }, }, "FLEEING": { "enter": function() { // We use the distance between the entities to account for ranged attacks this.order.data.distanceToFlee = DistanceBetweenEntities(this.entity, this.order.data.target) + (+this.template.FleeDistance); let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); // Use unit motion directly to ignore the visibility check. TODO: change this if we add LOS to fauna. if (this.CheckTargetRangeExplicit(this.order.data.target, this.order.data.distanceToFlee, -1) || !cmpUnitMotion || !cmpUnitMotion.MoveToTargetRange(this.order.data.target, this.order.data.distanceToFlee, -1)) { this.FinishOrder(); return true; } this.PlaySound("panic"); // Run quickly this.SetSpeedMultiplier(this.GetRunMultiplier()); }, "HealthChanged": function() { this.SetSpeedMultiplier(this.GetRunMultiplier()); }, "leave": function() { this.ResetSpeedMultiplier(); this.StopMoving(); }, "MovementUpdate": function(msg) { // When we've run far enough, stop fleeing if (msg.likelyFailure || this.CheckTargetRangeExplicit(this.order.data.target, this.order.data.distanceToFlee, -1)) this.FinishOrder(); }, // TODO: what if we run into more enemies while fleeing? }, "COMBAT": { "Order.LeaveFoundation": function(msg) { // Ignore the order as we're busy. return { "discardOrder": true }; }, "Attacked": function(msg) { // If we're already in combat mode, ignore anyone else who's attacking us // unless it's a melee attack since they may be blocking our way to the target if (msg.data.type == "Melee" && (this.GetStance().targetAttackersAlways || !this.order.data.force)) this.RespondToTargetedEntities([msg.data.attacker]); }, "APPROACHING": { "enter": function() { if (!this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType)) { this.FinishOrder(); return true; } // Show weapons rather than carried resources. this.SetAnimationVariant("combat"); this.StartTimer(1000, 1000); }, "leave": function() { // Show carried resources when walking. this.SetDefaultAnimationVariant(); this.StopMoving(); this.StopTimer(); }, "Timer": function(msg) { if (this.ShouldAbandonChase(this.order.data.target, this.order.data.force, IID_Attack, this.order.data.attackType)) { this.StopMoving(); this.FinishOrder(); // Return to our original position if (this.GetStance().respondHoldGround) this.WalkToHeldPosition(); } else { this.RememberTargetPosition(); if (this.order.data.hunting && this.orderQueue.length > 1 && this.orderQueue[1].type === "Gather") this.RememberTargetPosition(this.orderQueue[1].data); } }, "MovementUpdate": function(msg) { if (msg.likelyFailure) { // This also handles hunting. if (this.orderQueue.length > 1) { this.FinishOrder(); return; } else if (!this.order.data.force) { this.SetNextState("COMBAT.FINDINGNEWTARGET"); return; } // Go to the last known position and try to find enemies there. let lastPos = this.order.data.lastPos; this.PushOrder("WalkAndFight", { "x": lastPos.x, "z": lastPos.z, "force": false }); return; } if (this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType)) { // If the unit needs to unpack, do so if (this.CanUnpack()) { this.PushOrderFront("Unpack", { "force": true }); return; } this.SetNextState("ATTACKING"); } else if (msg.likelySuccess) // Try moving again, // attack range uses a height-related formula and our actual max range might have changed. if (!this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType)) this.FinishOrder(); }, }, "ATTACKING": { "enter": function() { let target = this.order.data.target; let cmpFormation = Engine.QueryInterface(target, IID_Formation); // if the target is a formation, save the attacking formation, and pick a member if (cmpFormation) { this.order.data.formationTarget = target; target = cmpFormation.GetClosestMember(this.entity); this.order.data.target = target; } if (!this.CanAttack(target)) { this.SetNextState("COMBAT.FINDINGNEWTARGET"); return true; } if (!this.CheckTargetAttackRange(target, this.order.data.attackType)) { if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return true; } this.SetNextState("COMBAT.APPROACHING"); return true; } this.StopMoving(); let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); this.attackTimers = cmpAttack.GetTimers(this.order.data.attackType); // If the repeat time since the last attack hasn't elapsed, // delay this attack to avoid attacking too fast. let prepare = this.attackTimers.prepare; if (this.lastAttacked) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); let repeatLeft = this.lastAttacked + this.attackTimers.repeat - cmpTimer.GetTime(); prepare = Math.max(prepare, repeatLeft); } if (!this.IsFormationMember()) this.SetAnimationVariant("combat"); this.oldAttackType = this.order.data.attackType; // add prefix + no capital first letter for attackType this.SelectAnimation("attack_" + this.order.data.attackType.toLowerCase()); this.SetAnimationSync(prepare, this.attackTimers.repeat); this.StartTimer(prepare, this.attackTimers.repeat); // TODO: we should probably only bother syncing projectile attacks, not melee // If using a non-default prepare time, re-sync the animation when the timer runs. this.resyncAnimation = prepare != this.attackTimers.prepare; this.FaceTowardsTarget(this.order.data.target); let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); if (cmpBuildingAI) cmpBuildingAI.SetUnitAITarget(this.order.data.target); }, "leave": function() { let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); if (cmpBuildingAI) cmpBuildingAI.SetUnitAITarget(0); this.StopTimer(); this.SetDefaultAnimationVariant(); this.ResetAnimation(); }, "Timer": function(msg) { let target = this.order.data.target; // Check the target is still alive and attackable if (!this.CanAttack(target)) { this.SetNextState("COMBAT.FINDINGNEWTARGET"); return; } this.RememberTargetPosition(); if (this.order.data.hunting && this.orderQueue.length > 1 && this.orderQueue[1].type === "Gather") this.RememberTargetPosition(this.orderQueue[1].data); let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); this.lastAttacked = cmpTimer.GetTime() - msg.lateness; this.FaceTowardsTarget(target); // BuildingAI has it's own attack-routine let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); if (!cmpBuildingAI) { let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); cmpAttack.PerformAttack(this.order.data.attackType, target); } // Check we can still reach the target for the next attack if (this.CheckTargetAttackRange(target, this.order.data.attackType)) { if (this.resyncAnimation) { this.SetAnimationSync(this.attackTimers.repeat, this.attackTimers.repeat); this.resyncAnimation = false; } return; } // Can't reach it - try to chase after it if (this.ShouldChaseTargetedEntity(target, this.order.data.force)) { if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } this.SetNextState("COMBAT.CHASING"); return; } this.SetNextState("FINDINGNEWTARGET"); }, // TODO: respond to target deaths immediately, rather than waiting // until the next Timer event "Attacked": function(msg) { // If we are capturing and are attacked by something that we would not capture, attack that entity instead if (this.order.data.attackType == "Capture" && (this.GetStance().targetAttackersAlways || !this.order.data.force) && this.order.data.target != msg.data.attacker && this.GetBestAttackAgainst(msg.data.attacker, true) != "Capture") this.RespondToTargetedEntities([msg.data.attacker]); }, }, "FINDINGNEWTARGET": { "enter": function() { // Try to find the formation the target was a part of. let cmpFormation = Engine.QueryInterface(this.order.data.target, IID_Formation); if (!cmpFormation) cmpFormation = Engine.QueryInterface(this.order.data.formationTarget || INVALID_ENTITY, IID_Formation); // If the target is a formation, pick closest member. if (cmpFormation) { let filter = (t) => this.CanAttack(t); this.order.data.formationTarget = this.order.data.target; let target = cmpFormation.GetClosestMember(this.entity, filter); this.order.data.target = target; this.SetNextState("COMBAT.ATTACKING"); return true; } // Can't reach it, no longer owned by enemy, or it doesn't exist any more - give up // except if in WalkAndFight mode where we look for more enemies around before moving again. if (this.FinishOrder()) { if (this.IsWalkingAndFighting()) this.FindWalkAndFightTargets(); return true; } // See if we can switch to a new nearby enemy if (this.FindNewTargets()) return true; // Return to our original position if (this.GetStance().respondHoldGround) this.WalkToHeldPosition(); return true; }, }, "CHASING": { "enter": function() { if (!this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType)) { this.FinishOrder(); return true; } // Show weapons rather than carried resources. this.SetAnimationVariant("combat"); var cmpUnitAI = Engine.QueryInterface(this.order.data.target, IID_UnitAI); if (cmpUnitAI && cmpUnitAI.IsFleeing()) { // Run after a fleeing target this.SetSpeedMultiplier(this.GetRunMultiplier()); } this.StartTimer(1000, 1000); }, "leave": function() { this.ResetSpeedMultiplier(); // Show carried resources when walking. this.SetDefaultAnimationVariant(); this.StopMoving(); this.StopTimer(); }, "Timer": function(msg) { if (this.ShouldAbandonChase(this.order.data.target, this.order.data.force, IID_Attack, this.order.data.attackType)) { this.StopMoving(); this.FinishOrder(); // Return to our original position if (this.GetStance().respondHoldGround) this.WalkToHeldPosition(); } }, "MovementUpdate": function(msg) { if (this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType)) { // If the unit needs to unpack, do so if (this.CanUnpack()) { this.PushOrderFront("Unpack", { "force": true }); return; } this.SetNextState("ATTACKING"); } else if (msg.likelySuccess) // Try moving again, // attack range uses a height-related formula and our actual max range might have changed. if (!this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType)) this.FinishOrder(); }, }, }, "GATHER": { "APPROACHING": { "enter": function() { this.gatheringTarget = this.order.data.target; // temporary, deleted in "leave". // check that we can gather from the resource we're supposed to gather from. var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); var cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); var cmpMirage = Engine.QueryInterface(this.gatheringTarget, IID_Mirage); if ((!cmpMirage || !cmpMirage.Mirages(IID_ResourceSupply)) && (!cmpSupply || !cmpSupply.AddGatherer(cmpOwnership.GetOwner(), this.entity)) || !this.MoveTo(this.order.data, IID_ResourceGatherer)) { // The GATHERING timer will handle finding a valid resource. this.SetNextState("GATHERING"); return true; } return false; }, "MovementUpdate": function(msg) { // The GATHERING timer will handle finding a valid resource. if (msg.likelyFailure || this.CheckRange(this.order.data, IID_ResourceGatherer)) this.SetNextState("GATHERING"); }, "leave": function() { this.StopMoving(); this.SetDefaultAnimationVariant(); if (!this.gatheringTarget) return; // don't use ownership because this is called after a conversion/resignation // and the ownership would be invalid then. var cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); if (cmpSupply) cmpSupply.RemoveGatherer(this.entity); delete this.gatheringTarget; }, }, // Walking to a good place to gather resources near, used by GatherNearPosition "WALKING": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { // If we failed, the GATHERING timer will handle finding a valid resource. if (msg.likelyFailure || msg.obstructed && this.RelaxedMaxRangeCheck(this.order.data, this.DefaultRelaxedMaxRange) || this.CheckRange(this.order.data)) this.SetNextState("GATHERING"); }, }, "GATHERING": { "enter": function() { this.gatheringTarget = this.order.data.target || INVALID_ENTITY; // deleted in "leave". // Check if the resource is full. // Will only be added if we're not already in. let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); let cmpSupply; if (cmpOwnership) cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); if (!cmpSupply || !cmpSupply.AddGatherer(cmpOwnership.GetOwner(), this.entity)) { this.StartTimer(0); return false; } // If this order was forced, the player probably gave it, but now we've reached the target // switch to an unforced order (can be interrupted by attacks) this.order.data.force = false; this.order.data.autoharvest = true; // Calculate timing based on gather rates // This allows the gather rate to control how often we gather, instead of how much. var cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); var rate = cmpResourceGatherer.GetTargetGatherRate(this.gatheringTarget); if (!rate) { // Try to find another target if the current one stopped existing if (!Engine.QueryInterface(this.gatheringTarget, IID_Identity)) { // Let the Timer logic handle this this.StartTimer(0); return false; } // No rate, give up on gathering this.FinishOrder(); return true; } // Scale timing interval based on rate, and start timer // The offset should be at least as long as the repeat time so we use the same value for both. var offset = 1000/rate; var repeat = offset; this.StartTimer(offset, repeat); // We want to start the gather animation as soon as possible, // but only if we're actually at the target and it's still alive // (else it'll look like we're chopping empty air). // (If it's not alive, the Timer handler will deal with sending us // off to a different target.) if (this.CheckTargetRange(this.gatheringTarget, IID_ResourceGatherer)) { this.StopMoving(); this.SetDefaultAnimationVariant(); this.FaceTowardsTarget(this.order.data.target); this.SelectAnimation("gather_" + this.order.data.type.specific); } return false; }, "leave": function() { this.StopTimer(); // don't use ownership because this is called after a conversion/resignation // and the ownership would be invalid then. var cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); if (cmpSupply) cmpSupply.RemoveGatherer(this.entity); delete this.gatheringTarget; // Show the carried resource, if we've gathered anything. this.ResetAnimation(); this.SetDefaultAnimationVariant(); }, "Timer": function(msg) { let resourceTemplate = this.order.data.template; let resourceType = this.order.data.type; let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership) return; let cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); if (cmpSupply && cmpSupply.IsAvailable(cmpOwnership.GetOwner(), this.entity)) // Check we can still reach and gather from the target if (this.CheckTargetRange(this.gatheringTarget, IID_ResourceGatherer) && this.CanGather(this.gatheringTarget)) { // Gather the resources: let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); // Try to gather treasure if (cmpResourceGatherer.TryInstantGather(this.gatheringTarget)) return; // If we've already got some resources but they're the wrong type, // drop them first to ensure we're only ever carrying one type if (cmpResourceGatherer.IsCarryingAnythingExcept(resourceType.generic)) cmpResourceGatherer.DropResources(); this.FaceTowardsTarget(this.order.data.target); // Collect from the target let status = cmpResourceGatherer.PerformGather(this.gatheringTarget); // If we've collected as many resources as possible, // return to the nearest dropsite if (status.filled) { let nearby = this.FindNearestDropsite(resourceType.generic); if (nearby) { // (Keep this Gather order on the stack so we'll // continue gathering after returning) this.PushOrderFront("ReturnResource", { "target": nearby, "force": false }); return; } // Oh no, couldn't find any drop sites. Give up on gathering. this.FinishOrder(); return; } // We can gather more from this target, do so in the next timer if (!status.exhausted) return; } else { // Try to follow the target if (this.MoveToTargetRange(this.gatheringTarget, IID_ResourceGatherer)) { this.SetNextState("APPROACHING"); return; } // Our target is no longer visible - go to its last known position first // and then hopefully it will become visible. if (!this.CheckTargetVisible(this.gatheringTarget) && this.order.data.lastPos) { this.PushOrderFront("Walk", { "x": this.order.data.lastPos.x, "z": this.order.data.lastPos.z, "force": this.order.data.force }); return; } } // We're already in range, can't get anywhere near it or the target is exhausted. // Give up on this order and try our next queued order // but first check what is our next order and, if needed, insert a returnResource order let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (cmpResourceGatherer.IsCarrying(resourceType.generic) && this.orderQueue.length > 1 && this.orderQueue[1] !== "ReturnResource" && (this.orderQueue[1].type !== "Gather" || this.orderQueue[1].data.type.generic !== resourceType.generic)) { let nearby = this.FindNearestDropsite(resourceType.generic); if (nearby) this.orderQueue.splice(1, 0, { "type": "ReturnResource", "data": { "target": nearby, "force": false } }); } // Must go before FinishOrder or this.order will be undefined. let initPos = this.order.data.initPos; if (this.FinishOrder()) return; // No remaining orders - pick a useful default behaviour if (!initPos) { let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) { let pos = cmpPosition.GetPosition(); initPos = { 'x': pos.X, 'z': pos.Z }; } } if (initPos) { // Try to find a new resource of the same specific type near the initial resource position: // Also don't switch to a different type of huntable animal let nearby = this.FindNearbyResource(function(ent, type, template) { return ( (type.generic == "treasure" && resourceType.generic == "treasure") || (type.specific == resourceType.specific && (type.specific != "meat" || resourceTemplate == template)) ); }, new Vector2D(initPos.x, initPos.z)); if (nearby) { this.PerformGather(nearby, false, false); return; } // Failing that, try to move there and se if we are more lucky: maybe there are resources in FOW. // Only move if we are some distance away (TODO: pick the distance better?) if (!this.CheckPointRangeExplicit(initPos.x, initPos.z, 0, 10)) { this.GatherNearPosition(initPos.x, initPos.z, resourceType, resourceTemplate); return; } } // Nothing else to gather - if we're carrying anything then we should // drop it off, and if not then we might as well head to the dropsite // anyway because that's a nice enough place to congregate and idle let nearby = this.FindNearestDropsite(resourceType.generic); if (nearby) { this.PushOrderFront("ReturnResource", { "target": nearby, "force": false }); return; } // No dropsites - just give up }, }, }, "HEAL": { "Attacked": function(msg) { // If we stand ground we will rather die than flee if (!this.GetStance().respondStandGround && !this.order.data.force) this.Flee(msg.data.attacker, false); }, "APPROACHING": { "enter": function() { if (this.CheckRange(this.order.data, IID_Heal)) { this.SetNextState("HEALING"); return true; } if (!this.MoveTo(this.order.data, IID_Heal)) { this.SetNextState("FINDINGNEWTARGET"); return true; } this.StartTimer(1000, 1000); }, "leave": function() { this.StopMoving(); this.StopTimer(); }, "Timer": function(msg) { if (this.ShouldAbandonChase(this.order.data.target, this.order.data.force, IID_Heal, null)) this.SetNextState("FINDINGNEWTARGET"); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || this.CheckRange(this.order.data, IID_Heal)) this.SetNextState("HEALING"); }, }, "HEALING": { "enter": function() { if (!this.CheckRange(this.order.data, IID_Heal)) { this.SetNextState("APPROACHING"); return true; } if (!this.TargetIsAlive(this.order.data.target) || !this.CanHeal(this.order.data.target)) { this.SetNextState("FINDINGNEWTARGET"); return true; } let cmpHeal = Engine.QueryInterface(this.entity, IID_Heal); this.healTimers = cmpHeal.GetTimers(); // If the repeat time since the last heal hasn't elapsed, // delay the action to avoid healing too fast. var prepare = this.healTimers.prepare; if (this.lastHealed) { var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); var repeatLeft = this.lastHealed + this.healTimers.repeat - cmpTimer.GetTime(); prepare = Math.max(prepare, repeatLeft); } this.SelectAnimation("heal"); this.SetAnimationSync(prepare, this.healTimers.repeat); this.StartTimer(prepare, this.healTimers.repeat); // If using a non-default prepare time, re-sync the animation when the timer runs. this.resyncAnimation = prepare != this.healTimers.prepare; this.FaceTowardsTarget(this.order.data.target); }, "leave": function() { this.ResetAnimation(); this.StopTimer(); }, "Timer": function(msg) { let target = this.order.data.target; // Check the target is still alive and healable if (!this.TargetIsAlive(target) || !this.CanHeal(target)) { this.SetNextState("FINDINGNEWTARGET"); return; } // Check if we can still reach the target if (!this.CheckRange(this.order.data, IID_Heal)) { if (this.ShouldChaseTargetedEntity(target, this.order.data.force)) { // Can't reach it - try to chase after it if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } this.SetNextState("HEAL.APPROACHING"); } else this.SetNextState("FINDINGNEWTARGET"); return; } let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); this.lastHealed = cmpTimer.GetTime() - msg.lateness; this.FaceTowardsTarget(target); let cmpHeal = Engine.QueryInterface(this.entity, IID_Heal); cmpHeal.PerformHeal(target); if (this.resyncAnimation) { this.SetAnimationSync(this.healTimers.repeat, this.healTimers.repeat); this.resyncAnimation = false; } }, }, "FINDINGNEWTARGET": { "enter": function() { // If we have another order, do that instead. if (this.FinishOrder()) return true; // Heal another one if (this.FindNewHealTargets()) return true; // Return to our original position if (this.GetStance().respondHoldGround) this.WalkToHeldPosition(); // We quit this state right away. return true; }, }, }, // Returning to dropsite "RETURNRESOURCE": { "APPROACHING": { "enter": function() { if (!this.MoveTo(this.order.data, IID_ResourceGatherer)) { this.FinishOrder(); return true; } }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { // Check the dropsite is in range and we can return our resource there // (we didn't get stopped before reaching it) if (this.CheckTargetRange(this.order.data.target, IID_ResourceGatherer) && this.CanReturnResource(this.order.data.target, true)) { let cmpResourceDropsite = Engine.QueryInterface(this.order.data.target, IID_ResourceDropsite); if (cmpResourceDropsite) { // Dump any resources we can let dropsiteTypes = cmpResourceDropsite.GetTypes(); let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); cmpResourceGatherer.CommitResources(dropsiteTypes); // Stop showing the carried resource animation. this.SetDefaultAnimationVariant(); // Our next order should always be a Gather, // so just switch back to that order this.FinishOrder(); return; } } if (msg.obstructed) return; // If we are here: we are in range but not carrying the right resources (or resources at all), // the dropsite was destroyed, or we couldn't reach it, or ownership changed. // Look for a new one. let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); let genericType = cmpResourceGatherer.GetMainCarryingType(); let nearby = this.FindNearestDropsite(genericType); if (nearby) { this.FinishOrder(); this.PushOrderFront("ReturnResource", { "target": nearby, "force": false }); return; } // Oh no, couldn't find any drop sites. Give up on returning. this.FinishOrder(); }, }, }, "TRADE": { "Attacked": function(msg) { // Ignore attack // TODO: Inform player }, "APPROACHINGMARKET": { "enter": function() { if (!this.MoveToMarket(this.order.data.target)) { this.FinishOrder(); return true; } }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (!msg.likelyFailure && !this.CheckTargetRange(this.order.data.target, IID_Trader)) return; if (this.waypoints && this.waypoints.length) { if (!this.MoveToMarket(this.order.data.target)) this.StopTrading(); } else this.PerformTradeAndMoveToNextMarket(this.order.data.target); }, }, "TradingCanceled": function(msg) { if (msg.market != this.order.data.target) return; let cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); let otherMarket = cmpTrader && cmpTrader.GetFirstMarket(); this.StopTrading(); if (otherMarket) this.WalkToTarget(otherMarket); }, }, "REPAIR": { "APPROACHING": { "enter": function() { if (!this.MoveTo(this.order.data, IID_Builder)) { this.FinishOrder(); return true; } }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || msg.likelySuccess) this.SetNextState("REPAIRING"); }, }, "REPAIRING": { "enter": function() { // If this order was forced, the player probably gave it, but now we've reached the target // switch to an unforced order (can be interrupted by attacks) if (this.order.data.force) this.order.data.autoharvest = true; this.order.data.force = false; this.repairTarget = this.order.data.target; // temporary, deleted in "leave". // Check we can still reach and repair the target if (!this.CanRepair(this.repairTarget)) { // Can't reach it, no longer owned by ally, or it doesn't exist any more this.FinishOrder(); return true; } if (!this.CheckTargetRange(this.repairTarget, IID_Builder)) { this.SetNextState("APPROACHING"); return true; } // Check if the target is still repairable var cmpHealth = Engine.QueryInterface(this.repairTarget, IID_Health); if (cmpHealth && cmpHealth.GetHitpoints() >= cmpHealth.GetMaxHitpoints()) { // The building was already finished/fully repaired before we arrived; // let the ConstructionFinished handler handle this. this.OnGlobalConstructionFinished({"entity": this.repairTarget, "newentity": this.repairTarget}); return true; } this.StopMoving(); let cmpBuilderList = QueryBuilderListInterface(this.repairTarget); if (cmpBuilderList) cmpBuilderList.AddBuilder(this.entity); this.FaceTowardsTarget(this.order.data.target); this.SelectAnimation("build"); this.StartTimer(1000, 1000); return false; }, "leave": function() { let cmpBuilderList = QueryBuilderListInterface(this.repairTarget); if (cmpBuilderList) cmpBuilderList.RemoveBuilder(this.entity); delete this.repairTarget; this.StopTimer(); this.ResetAnimation(); }, "Timer": function(msg) { // Check we can still reach and repair the target if (!this.CanRepair(this.repairTarget)) { // No longer owned by ally, or it doesn't exist any more this.FinishOrder(); return; } this.FaceTowardsTarget(this.order.data.target); let cmpBuilder = Engine.QueryInterface(this.entity, IID_Builder); cmpBuilder.PerformBuilding(this.repairTarget); // if the building is completed, the leave() function will be called // by the ConstructionFinished message // in that case, the repairTarget is deleted, and we can just return if (!this.repairTarget) return; if (!this.CheckTargetRange(this.repairTarget, IID_Builder)) this.SetNextState("APPROACHING"); }, }, "ConstructionFinished": function(msg) { if (msg.data.entity != this.order.data.target) return; // ignore other buildings // Save the current order's data in case we need it later let oldData = this.order.data; // Save the current state so we can continue walking if necessary // FinishOrder() below will switch to IDLE if there's no order, which sets the idle animation. // Idle animation while moving towards finished construction looks weird (ghosty). let oldState = this.GetCurrentState(); // Drop any resource we can if we are in range when the construction finishes let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); let cmpResourceDropsite = Engine.QueryInterface(msg.data.newentity, IID_ResourceDropsite); if (cmpResourceGatherer && cmpResourceDropsite && this.CheckTargetRange(msg.data.newentity, IID_Builder) && this.CanReturnResource(msg.data.newentity, true)) { let dropsiteTypes = cmpResourceDropsite.GetTypes(); cmpResourceGatherer.CommitResources(dropsiteTypes); this.SetDefaultAnimationVariant(); } // We finished building it. // Switch to the next order (if any) if (this.FinishOrder()) { if (this.CanReturnResource(msg.data.newentity, true)) { this.SetDefaultAnimationVariant(); this.PushOrderFront("ReturnResource", { "target": msg.data.newentity, "force": false }); } return; } // No remaining orders - pick a useful default behaviour // If autocontinue explicitly disabled (e.g. by AI) then // do nothing automatically if (!oldData.autocontinue) return; // If this building was e.g. a farm of ours, the entities that received // the build command should start gathering from it if ((oldData.force || oldData.autoharvest) && this.CanGather(msg.data.newentity)) { if (this.CanReturnResource(msg.data.newentity, true)) { this.SetDefaultAnimationVariant(); this.PushOrder("ReturnResource", { "target": msg.data.newentity, "force": false }); } this.PerformGather(msg.data.newentity, true, false); return; } // If this building was e.g. a farmstead of ours, entities that received // the build command should look for nearby resources to gather if ((oldData.force || oldData.autoharvest) && this.CanReturnResource(msg.data.newentity, false)) { let types = cmpResourceDropsite.GetTypes(); let pos; let cmpPosition = Engine.QueryInterface(msg.data.newentity, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) pos = cmpPosition.GetPosition2D(); // TODO: Slightly undefined behavior here, we don't know what type of resource will be collected, // may cause problems for AIs (especially hunting fast animals), but avoid ugly hacks to fix that! let nearby = this.FindNearbyResource(function(ent, type, template) { return (types.indexOf(type.generic) != -1); }, pos); if (nearby) { this.PerformGather(nearby, true, false); return; } } // Look for a nearby foundation to help with let nearbyFoundation = this.FindNearbyFoundation(); if (nearbyFoundation) { this.AddOrder("Repair", { "target": nearbyFoundation, "autocontinue": oldData.autocontinue, "force": false }, true); return; } // Unit was approaching and there's nothing to do now, so switch to walking if (oldState === "INDIVIDUAL.REPAIR.APPROACHING") // We're already walking to the given point, so add this as a order. this.WalkToTarget(msg.data.newentity, true); }, }, "GARRISON": { "enter": function() { // If the garrisonholder should pickup, warn it so it can take needed action var cmpGarrisonHolder = Engine.QueryInterface(this.order.data.target, IID_GarrisonHolder); if (cmpGarrisonHolder && cmpGarrisonHolder.CanPickup(this.entity)) { this.pickup = this.order.data.target; // temporary, deleted in "leave" Engine.PostMessage(this.pickup, MT_PickupRequested, { "entity": this.entity }); } }, "leave": function() { // If a pickup has been requested and not yet canceled, cancel it if (this.pickup) { Engine.PostMessage(this.pickup, MT_PickupCanceled, { "entity": this.entity }); delete this.pickup; } }, "APPROACHING": { "enter": function() { if (!this.MoveToGarrisonRange(this.order.data.target)) { this.FinishOrder(); return true; } }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || msg.likelySuccess) this.SetNextState("GARRISONED"); }, }, "GARRISONED": { "enter": function() { let target = this.order.data.target; if (!target) { this.FinishOrder(); return true; } if (this.IsGarrisoned()) return false; // Check that we can garrison here if (this.CanGarrison(target)) // Check that we're in range of the garrison target if (this.CheckGarrisonRange(target)) { var cmpGarrisonHolder = Engine.QueryInterface(target, IID_GarrisonHolder); // Check that garrisoning succeeds if (cmpGarrisonHolder.Garrison(this.entity)) { this.isGarrisoned = true; if (this.formationController) { var cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) { // disable rearrange for this removal, // but enable it again for the next // move command var rearrange = cmpFormation.rearrange; cmpFormation.SetRearrange(false); cmpFormation.RemoveMembers([this.entity]); cmpFormation.SetRearrange(rearrange); } } // Check if we are garrisoned in a dropsite var cmpResourceDropsite = Engine.QueryInterface(target, IID_ResourceDropsite); if (cmpResourceDropsite && this.CanReturnResource(target, true)) { // Dump any resources we can var dropsiteTypes = cmpResourceDropsite.GetTypes(); var cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (cmpResourceGatherer) { cmpResourceGatherer.CommitResources(dropsiteTypes); this.SetDefaultAnimationVariant(); } } // If a pickup has been requested, remove it if (this.pickup) { var cmpHolderPosition = Engine.QueryInterface(target, IID_Position); var cmpHolderUnitAI = Engine.QueryInterface(target, IID_UnitAI); if (cmpHolderUnitAI && cmpHolderPosition) cmpHolderUnitAI.lastShorelinePosition = cmpHolderPosition.GetPosition(); Engine.PostMessage(this.pickup, MT_PickupCanceled, { "entity": this.entity }); delete this.pickup; } if (this.IsTurret()) { this.SetNextState("IDLE"); return true; } return false; } } else { // Unable to reach the target, try again (or follow if it is a moving target) // except if the does not exits anymore or its orders have changed if (this.pickup) { var cmpUnitAI = Engine.QueryInterface(this.pickup, IID_UnitAI); if (!cmpUnitAI || !cmpUnitAI.HasPickupOrder(this.entity)) { this.FinishOrder(); return true; } } this.SetNextState("APPROACHING"); return true; } // Garrisoning failed for some reason, so finish the order this.FinishOrder(); return true; }, "leave": function() { } }, }, "CHEERING": { "enter": function() { // Unit is invulnerable while cheering - var cmpDamageReceiver = Engine.QueryInterface(this.entity, IID_DamageReceiver); - cmpDamageReceiver.SetInvulnerability(true); + var cmpResistance = Engine.QueryInterface(this.entity, IID_Resistance); + cmpResistance.SetInvulnerability(true); this.SelectAnimation("promotion"); this.StartTimer(2800, 2800); return false; }, "leave": function() { this.StopTimer(); this.ResetAnimation(); - var cmpDamageReceiver = Engine.QueryInterface(this.entity, IID_DamageReceiver); - cmpDamageReceiver.SetInvulnerability(false); + var cmpResistance = Engine.QueryInterface(this.entity, IID_Resistance); + cmpResistance.SetInvulnerability(false); }, "Timer": function(msg) { this.FinishOrder(); }, }, "PACKING": { "enter": function() { this.StopMoving(); var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); cmpPack.Pack(); }, "PackFinished": function(msg) { this.FinishOrder(); }, "leave": function() { }, "Attacked": function(msg) { // Ignore attacks while packing }, }, "UNPACKING": { "enter": function() { this.StopMoving(); var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); cmpPack.Unpack(); }, "PackFinished": function(msg) { this.FinishOrder(); }, "leave": function() { }, "Attacked": function(msg) { // Ignore attacks while unpacking }, }, "PICKUP": { "APPROACHING": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || msg.likelySuccess) this.SetNextState("LOADING"); }, "PickupCanceled": function() { this.StopMoving(); this.FinishOrder(); }, }, "LOADING": { "enter": function() { this.StopMoving(); var cmpGarrisonHolder = Engine.QueryInterface(this.entity, IID_GarrisonHolder); if (!cmpGarrisonHolder || cmpGarrisonHolder.IsFull()) { this.FinishOrder(); return true; } return false; }, "PickupCanceled": function() { this.FinishOrder(); }, }, }, }, "ANIMAL": { "Attacked": function(msg) { if (this.template.NaturalBehaviour == "skittish" || this.template.NaturalBehaviour == "passive") { this.Flee(msg.data.attacker, false); } else if (this.IsDangerousAnimal() || this.template.NaturalBehaviour == "defensive") { if (this.CanAttack(msg.data.attacker)) this.Attack(msg.data.attacker, false); } else if (this.template.NaturalBehaviour == "domestic") { // Never flee, stop what we were doing this.SetNextState("IDLE"); } }, "Order.LeaveFoundation": function(msg) { // Move a tile outside the building let range = 4; if (this.CheckTargetRangeExplicit(msg.data.target, range, -1)) { this.FinishOrder(); return; } this.order.data.min = range; this.SetNextState("WALKING"); }, "IDLE": { // (We need an IDLE state so that FinishOrder works) "enter": function() { // Start feeding immediately this.SetNextState("FEEDING"); return true; }, }, "ROAMING": { "enter": function() { // Walk in a random direction this.SetFacePointAfterMove(false); this.MoveRandomly(+this.template.RoamDistance); // Set a random timer to switch to feeding state this.StartTimer(randIntInclusive(+this.template.RoamTimeMin, +this.template.RoamTimeMax)); }, "leave": function() { this.StopMoving(); this.StopTimer(); this.SetFacePointAfterMove(true); }, "LosRangeUpdate": function(msg) { if (this.template.NaturalBehaviour == "skittish") { if (msg.data.added.length > 0) { this.Flee(msg.data.added[0], false); return; } } // Start attacking one of the newly-seen enemy (if any) else if (this.IsDangerousAnimal()) { this.AttackVisibleEntity(msg.data.added); } // TODO: if two units enter our range together, we'll attack the // first and then the second won't trigger another LosRangeUpdate // so we won't notice it. Probably we should do something with // ResetActiveQuery in ROAMING.enter/FEEDING.enter in order to // find any units that are already in range. }, "Timer": function(msg) { this.SetNextState("FEEDING"); }, "MovementUpdate": function() { this.MoveRandomly(+this.template.RoamDistance); }, }, "FEEDING": { "enter": function() { // Stop and eat for a while this.SelectAnimation("feeding"); this.StopMoving(); this.StartTimer(randIntInclusive(+this.template.FeedTimeMin, +this.template.FeedTimeMax)); }, "leave": function() { this.ResetAnimation(); this.StopTimer(); }, "LosRangeUpdate": function(msg) { if (this.template.NaturalBehaviour == "skittish") { if (msg.data.added.length > 0) { this.Flee(msg.data.added[0], false); return; } } // Start attacking one of the newly-seen enemy (if any) else if (this.template.NaturalBehaviour == "violent") { this.AttackVisibleEntity(msg.data.added); } }, "Timer": function(msg) { this.SetNextState("ROAMING"); }, }, "FLEEING": "INDIVIDUAL.FLEEING", // reuse the same fleeing behaviour for animals "COMBAT": "INDIVIDUAL.COMBAT", // reuse the same combat behaviour for animals "WALKING": "INDIVIDUAL.WALKING", // reuse the same walking behaviour for animals // only used for domestic animals }, }; UnitAI.prototype.Init = function() { this.orderQueue = []; // current order is at the front of the list this.order = undefined; // always == this.orderQueue[0] this.formationController = INVALID_ENTITY; // entity with IID_Formation that we belong to this.isGarrisoned = false; this.isIdle = false; this.finishedOrder = false; // used to find if all formation members finished the order this.heldPosition = undefined; // Queue of remembered works this.workOrders = []; this.isGuardOf = undefined; // For preventing increased action rate due to Stop orders or target death. this.lastAttacked = undefined; this.lastHealed = undefined; this.SetStance(this.template.DefaultStance); }; UnitAI.prototype.IsTurret = function() { if (!this.IsGarrisoned()) return false; var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); return cmpPosition && cmpPosition.GetTurretParent() != INVALID_ENTITY; }; UnitAI.prototype.IsFormationController = function() { return (this.template.FormationController == "true"); }; UnitAI.prototype.IsFormationMember = function() { return (this.formationController != INVALID_ENTITY); }; UnitAI.prototype.HasFinishedOrder = function() { return this.finishedOrder; }; UnitAI.prototype.ResetFinishOrder = function() { this.finishedOrder = false; }; UnitAI.prototype.IsAnimal = function() { return (this.template.NaturalBehaviour ? true : false); }; UnitAI.prototype.IsDangerousAnimal = function() { return (this.IsAnimal() && (this.template.NaturalBehaviour == "violent" || this.template.NaturalBehaviour == "aggressive")); }; UnitAI.prototype.IsDomestic = function() { var cmpIdentity = Engine.QueryInterface(this.entity, IID_Identity); return cmpIdentity && cmpIdentity.HasClass("Domestic"); }; UnitAI.prototype.IsHealer = function() { return Engine.QueryInterface(this.entity, IID_Heal); }; UnitAI.prototype.IsIdle = function() { return this.isIdle; }; UnitAI.prototype.IsGarrisoned = function() { return this.isGarrisoned; }; UnitAI.prototype.SetGarrisoned = function() { this.isGarrisoned = true; }; UnitAI.prototype.GetGarrisonHolder = function() { if (this.IsGarrisoned()) { for (let order of this.orderQueue) if (order.type == "Garrison") return order.data.target; } return INVALID_ENTITY; }; UnitAI.prototype.ShouldRespondToEndOfAlert = function() { return !this.orderQueue.length || this.orderQueue[0].type == "Garrison"; }; UnitAI.prototype.IsFleeing = function() { var state = this.GetCurrentState().split(".").pop(); return (state == "FLEEING"); }; UnitAI.prototype.IsWalking = function() { var state = this.GetCurrentState().split(".").pop(); return (state == "WALKING"); }; /** * Return true if the current order is WalkAndFight or Patrol. */ UnitAI.prototype.IsWalkingAndFighting = function() { if (this.IsFormationMember()) return false; return this.orderQueue.length > 0 && (this.orderQueue[0].type == "WalkAndFight" || this.orderQueue[0].type == "Patrol"); }; UnitAI.prototype.OnCreate = function() { if (this.IsAnimal()) this.UnitFsm.Init(this, "ANIMAL.FEEDING"); else if (this.IsFormationController()) this.UnitFsm.Init(this, "FORMATIONCONTROLLER.IDLE"); else this.UnitFsm.Init(this, "INDIVIDUAL.IDLE"); this.isIdle = true; }; UnitAI.prototype.OnDiplomacyChanged = function(msg) { let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (cmpOwnership && cmpOwnership.GetOwner() == msg.player) this.SetupRangeQueries(); if (this.isGuardOf && !IsOwnedByMutualAllyOfEntity(this.entity, this.isGuardOf)) this.RemoveGuard(); }; UnitAI.prototype.OnOwnershipChanged = function(msg) { this.SetupRangeQueries(); if (this.isGuardOf && (msg.to == INVALID_PLAYER || !IsOwnedByMutualAllyOfEntity(this.entity, this.isGuardOf))) this.RemoveGuard(); // If the unit isn't being created or dying, reset stance and clear orders if (msg.to != INVALID_PLAYER && msg.from != INVALID_PLAYER) { // Switch to a virgin state to let states execute their leave handlers. // except if garrisoned or cheering or (un)packing, in which case we only clear the order queue if (this.isGarrisoned || this.IsPacking() || this.orderQueue[0] && this.orderQueue[0].type == "Cheering") { this.orderQueue.length = Math.min(this.orderQueue.length, 1); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); } else { let index = this.GetCurrentState().indexOf("."); if (index != -1) this.UnitFsm.SwitchToNextState(this, this.GetCurrentState().slice(0,index)); this.Stop(false); } this.workOrders = []; let cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); if (cmpTrader) cmpTrader.StopTrading(); this.SetStance(this.template.DefaultStance); if (this.IsTurret()) this.SetTurretStance(); } }; UnitAI.prototype.OnDestroy = function() { // Switch to an empty state to let states execute their leave handlers. this.UnitFsm.SwitchToNextState(this, ""); // Clean up range queries var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losRangeQuery) cmpRangeManager.DestroyActiveQuery(this.losRangeQuery); if (this.losHealRangeQuery) cmpRangeManager.DestroyActiveQuery(this.losHealRangeQuery); }; UnitAI.prototype.OnVisionRangeChanged = function(msg) { // Update range queries if (this.entity == msg.entity) this.SetupRangeQueries(); }; UnitAI.prototype.HasPickupOrder = function(entity) { return this.orderQueue.some(order => order.type == "PickupUnit" && order.data.target == entity); }; UnitAI.prototype.OnPickupRequested = function(msg) { // First check if we already have such a request if (this.HasPickupOrder(msg.entity)) return; // Otherwise, insert the PickUp order after the last forced order this.PushOrderAfterForced("PickupUnit", { "target": msg.entity }); }; UnitAI.prototype.OnPickupCanceled = function(msg) { for (let i = 0; i < this.orderQueue.length; ++i) { if (this.orderQueue[i].type != "PickupUnit" || this.orderQueue[i].data.target != msg.entity) continue; if (i == 0) this.UnitFsm.ProcessMessage(this, {"type": "PickupCanceled", "data": msg}); else this.orderQueue.splice(i, 1); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); break; } }; // Wrapper function that sets up the normal and healer range queries. UnitAI.prototype.SetupRangeQueries = function() { this.SetupRangeQuery(); if (this.IsHealer()) this.SetupHealRangeQuery(); }; UnitAI.prototype.UpdateRangeQueries = function() { var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losRangeQuery) this.SetupRangeQuery(cmpRangeManager.IsActiveQueryEnabled(this.losRangeQuery)); if (this.IsHealer() && this.losHealRangeQuery) this.SetupHealRangeQuery(cmpRangeManager.IsActiveQueryEnabled(this.losHealRangeQuery)); }; // Set up a range query for all enemy and gaia units within LOS range // which can be attacked. // This should be called whenever our ownership changes. UnitAI.prototype.SetupRangeQuery = function(enable = true) { var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losRangeQuery) { cmpRangeManager.DestroyActiveQuery(this.losRangeQuery); this.losRangeQuery = undefined; } var cmpPlayer = QueryOwnerInterface(this.entity); // If we are being destructed (owner -1), creating a range query is pointless if (!cmpPlayer) return; // Exclude allies, and self // TODO: How to handle neutral players - Special query to attack military only? var players = cmpPlayer.GetEnemies(); var range = this.GetQueryRange(IID_Attack); - this.losRangeQuery = cmpRangeManager.CreateActiveQuery(this.entity, range.min, range.max, players, IID_DamageReceiver, cmpRangeManager.GetEntityFlagMask("normal")); + this.losRangeQuery = cmpRangeManager.CreateActiveQuery(this.entity, range.min, range.max, players, IID_Resistance, cmpRangeManager.GetEntityFlagMask("normal")); if (enable) cmpRangeManager.EnableActiveQuery(this.losRangeQuery); }; // Set up a range query for all own or ally units within LOS range // which can be healed. // This should be called whenever our ownership changes. UnitAI.prototype.SetupHealRangeQuery = function(enable = true) { var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losHealRangeQuery) { cmpRangeManager.DestroyActiveQuery(this.losHealRangeQuery); this.losHealRangeQuery = undefined; } var cmpPlayer = QueryOwnerInterface(this.entity); // If we are being destructed (owner -1), creating a range query is pointless if (!cmpPlayer) return; var players = cmpPlayer.GetAllies(); var range = this.GetQueryRange(IID_Heal); this.losHealRangeQuery = cmpRangeManager.CreateActiveQuery(this.entity, range.min, range.max, players, IID_Health, cmpRangeManager.GetEntityFlagMask("injured")); if (enable) cmpRangeManager.EnableActiveQuery(this.losHealRangeQuery); }; //// FSM linkage functions //// // Setting the next state to the current state will leave/re-enter the top-most substate. UnitAI.prototype.SetNextState = function(state) { this.UnitFsm.SetNextState(this, state); }; UnitAI.prototype.DeferMessage = function(msg) { this.UnitFsm.DeferMessage(this, msg); }; UnitAI.prototype.GetCurrentState = function() { return this.UnitFsm.GetCurrentState(this); }; UnitAI.prototype.FsmStateNameChanged = function(state) { Engine.PostMessage(this.entity, MT_UnitAIStateChanged, { "to": state }); }; /** * Call when the current order has been completed (or failed). * Removes the current order from the queue, and processes the * next one (if any). Returns false and defaults to IDLE * if there are no remaining orders or if the unit is not * inWorld and not garrisoned (thus usually waiting to be destroyed). */ UnitAI.prototype.FinishOrder = function() { if (!this.orderQueue.length) { let stack = new Error().stack.trimRight().replace(/^/mg, ' '); // indent each line let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); let template = cmpTemplateManager.GetCurrentTemplateName(this.entity); error("FinishOrder called for entity " + this.entity + " (" + template + ") when order queue is empty\n" + stack); } this.orderQueue.shift(); this.order = this.orderQueue[0]; let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (this.orderQueue.length && (this.IsGarrisoned() || cmpPosition && cmpPosition.IsInWorld())) { let ret = this.UnitFsm.ProcessMessage(this, { "type": "Order."+this.order.type, "data": this.order.data } ); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); // If the order was rejected then immediately take it off // and process the remaining queue if (ret && ret.discardOrder) return this.FinishOrder(); // Otherwise we've successfully processed a new order return true; } this.orderQueue = []; this.order = undefined; // Switch to IDLE as a default state. this.SetNextState("IDLE"); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); // Check if there are queued formation orders if (this.IsFormationMember()) { let cmpUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); if (cmpUnitAI) { // Inform the formation controller that we finished this task this.finishedOrder = true; // We don't want to carry out the default order // if there are still queued formation orders left if (cmpUnitAI.GetOrders().length > 1) return true; } } return false; }; /** * Add an order onto the back of the queue, * and execute it if we didn't already have an order. */ UnitAI.prototype.PushOrder = function(type, data) { var order = { "type": type, "data": data }; this.orderQueue.push(order); // If we didn't already have an order, then process this new one if (this.orderQueue.length == 1) { this.order = order; let ret = this.UnitFsm.ProcessMessage(this, { "type": "Order."+this.order.type, "data": this.order.data } ); // If the order was rejected then immediately take it off // and process the remaining queue if (ret && ret.discardOrder) this.FinishOrder(); } Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); }; /** * Add an order onto the front of the queue, * and execute it immediately. */ UnitAI.prototype.PushOrderFront = function(type, data) { var order = { "type": type, "data": data }; // If current order is cheering then add new order after it // same thing if current order if packing/unpacking if (this.order && this.order.type == "Cheering") { var cheeringOrder = this.orderQueue.shift(); this.orderQueue.unshift(cheeringOrder, order); } else if (this.order && this.IsPacking()) { var packingOrder = this.orderQueue.shift(); this.orderQueue.unshift(packingOrder, order); } else { this.orderQueue.unshift(order); this.order = order; let ret = this.UnitFsm.ProcessMessage(this, { "type": "Order."+this.order.type, "data": this.order.data } ); // If the order was rejected then immediately take it off again; // assume the previous active order is still valid (the short-lived // new order hasn't changed state or anything) so we can carry on // as if nothing had happened if (ret && ret.discardOrder) { this.orderQueue.shift(); this.order = this.orderQueue[0]; } } Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); }; /** * Insert an order after the last forced order onto the queue * and after the other orders of the same type */ UnitAI.prototype.PushOrderAfterForced = function(type, data) { if (!this.order || ((!this.order.data || !this.order.data.force) && this.order.type != type)) this.PushOrderFront(type, data); else { for (let i = 1; i < this.orderQueue.length; ++i) { if (this.orderQueue[i].data && this.orderQueue[i].data.force) continue; if (this.orderQueue[i].type == type) continue; this.orderQueue.splice(i, 0, {"type": type, "data": data}); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); return; } this.PushOrder(type, data); } Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); }; UnitAI.prototype.ReplaceOrder = function(type, data) { // Remember the previous work orders to be able to go back to them later if required if (data && data.force) { if (this.IsFormationController()) this.CallMemberFunction("UpdateWorkOrders", [type]); else this.UpdateWorkOrders(type); } let garrisonHolder = this.IsGarrisoned() && type != "Ungarrison" ? this.GetGarrisonHolder() : null; // Special cases of orders that shouldn't be replaced: // 1. Cheering - we're invulnerable, add order after we finish // 2. Packing/unpacking - we're immobile, add order after we finish (unless it's cancel) // TODO: maybe a better way of doing this would be to use priority levels if (this.order && this.order.type == "Cheering") { var order = { "type": type, "data": data }; var cheeringOrder = this.orderQueue.shift(); this.orderQueue = [cheeringOrder, order]; } else if (this.IsPacking() && type != "CancelPack" && type != "CancelUnpack") { var order = { "type": type, "data": data }; var packingOrder = this.orderQueue.shift(); this.orderQueue = [packingOrder, order]; } else { this.orderQueue = []; this.PushOrder(type, data); } if (garrisonHolder) this.PushOrder("Garrison", { "target": garrisonHolder }); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); }; UnitAI.prototype.GetOrders = function() { return this.orderQueue.slice(); }; UnitAI.prototype.AddOrders = function(orders) { orders.forEach(order => this.PushOrder(order.type, order.data)); }; UnitAI.prototype.GetOrderData = function() { var orders = []; for (let order of this.orderQueue) if (order.data) orders.push(clone(order.data)); return orders; }; UnitAI.prototype.UpdateWorkOrders = function(type) { var isWorkType = type => type == "Gather" || type == "Trade" || type == "Repair" || type == "ReturnResource"; // If we are being re-affected to a work order, forget the previous ones if (isWorkType(type)) { this.workOrders = []; return; } // Then if we already have work orders, keep them if (this.workOrders.length) return; // First if the unit is in a formation, get its workOrders from it if (this.IsFormationMember()) { var cmpUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); if (cmpUnitAI) { for (var i = 0; i < cmpUnitAI.orderQueue.length; ++i) { if (isWorkType(cmpUnitAI.orderQueue[i].type)) { this.workOrders = cmpUnitAI.orderQueue.slice(i); return; } } } } // If nothing found, take the unit orders for (var i = 0; i < this.orderQueue.length; ++i) { if (isWorkType(this.orderQueue[i].type)) { this.workOrders = this.orderQueue.slice(i); return; } } }; UnitAI.prototype.BackToWork = function() { if (this.workOrders.length == 0) return false; if (this.IsGarrisoned()) { let cmpGarrisonHolder = Engine.QueryInterface(this.GetGarrisonHolder(), IID_GarrisonHolder); if (!cmpGarrisonHolder || !cmpGarrisonHolder.PerformEject([this.entity], false)) return false; } // Clear the order queue considering special orders not to avoid if (this.order && this.order.type == "Cheering") { var cheeringOrder = this.orderQueue.shift(); this.orderQueue = [cheeringOrder]; } else this.orderQueue = []; this.AddOrders(this.workOrders); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); // And if the unit is in a formation, remove it from the formation if (this.IsFormationMember()) { var cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) cmpFormation.RemoveMembers([this.entity]); } this.workOrders = []; return true; }; UnitAI.prototype.HasWorkOrders = function() { return this.workOrders.length > 0; }; UnitAI.prototype.GetWorkOrders = function() { return this.workOrders; }; UnitAI.prototype.SetWorkOrders = function(orders) { this.workOrders = orders; }; UnitAI.prototype.TimerHandler = function(data, lateness) { // Reset the timer if (data.timerRepeat === undefined) this.timer = undefined; this.UnitFsm.ProcessMessage(this, {"type": "Timer", "data": data, "lateness": lateness}); }; /** * Set up the UnitAI timer to run after 'offset' msecs, and then * every 'repeat' msecs until StopTimer is called. A "Timer" message * will be sent each time the timer runs. */ UnitAI.prototype.StartTimer = function(offset, repeat) { if (this.timer) error("Called StartTimer when there's already an active timer"); var data = { "timerRepeat": repeat }; var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); if (repeat === undefined) this.timer = cmpTimer.SetTimeout(this.entity, IID_UnitAI, "TimerHandler", offset, data); else this.timer = cmpTimer.SetInterval(this.entity, IID_UnitAI, "TimerHandler", offset, repeat, data); }; /** * Stop the current UnitAI timer. */ UnitAI.prototype.StopTimer = function() { if (!this.timer) return; var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.CancelTimer(this.timer); this.timer = undefined; }; UnitAI.prototype.OnMotionUpdate = function(msg) { this.UnitFsm.ProcessMessage(this, Object.assign({ "type": "MovementUpdate" }, msg)); }; UnitAI.prototype.OnGlobalConstructionFinished = function(msg) { // TODO: This is a bit inefficient since every unit listens to every // construction message - ideally we could scope it to only the one we're building this.UnitFsm.ProcessMessage(this, {"type": "ConstructionFinished", "data": msg}); }; UnitAI.prototype.OnGlobalEntityRenamed = function(msg) { let changed = false; for (let order of this.orderQueue) { if (order.data && order.data.target && order.data.target == msg.entity) { changed = true; order.data.target = msg.newentity; } if (order.data && order.data.formationTarget && order.data.formationTarget == msg.entity) { changed = true; order.data.formationTarget = msg.newentity; } } if (changed) Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); }; UnitAI.prototype.OnAttacked = function(msg) { this.UnitFsm.ProcessMessage(this, {"type": "Attacked", "data": msg}); }; UnitAI.prototype.OnGuardedAttacked = function(msg) { this.UnitFsm.ProcessMessage(this, {"type": "GuardedAttacked", "data": msg.data}); }; UnitAI.prototype.OnHealthChanged = function(msg) { this.UnitFsm.ProcessMessage(this, {"type": "HealthChanged", "from": msg.from, "to": msg.to}); }; UnitAI.prototype.OnRangeUpdate = function(msg) { if (msg.tag == this.losRangeQuery) this.UnitFsm.ProcessMessage(this, {"type": "LosRangeUpdate", "data": msg}); else if (msg.tag == this.losHealRangeQuery) this.UnitFsm.ProcessMessage(this, {"type": "LosHealRangeUpdate", "data": msg}); }; UnitAI.prototype.OnPackFinished = function(msg) { this.UnitFsm.ProcessMessage(this, {"type": "PackFinished", "packed": msg.packed}); }; //// Helper functions to be called by the FSM //// UnitAI.prototype.GetWalkSpeed = function() { let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (!cmpUnitMotion) return 0; return cmpUnitMotion.GetWalkSpeed(); }; UnitAI.prototype.GetRunMultiplier = function() { var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (!cmpUnitMotion) return 0; return cmpUnitMotion.GetRunMultiplier(); }; /** * Returns true if the target exists and has non-zero hitpoints. */ UnitAI.prototype.TargetIsAlive = function(ent) { var cmpFormation = Engine.QueryInterface(ent, IID_Formation); if (cmpFormation) return true; var cmpHealth = QueryMiragedInterface(ent, IID_Health); return cmpHealth && cmpHealth.GetHitpoints() != 0; }; /** * Returns true if the target exists and needs to be killed before * beginning to gather resources from it. */ UnitAI.prototype.MustKillGatherTarget = function(ent) { var cmpResourceSupply = Engine.QueryInterface(ent, IID_ResourceSupply); if (!cmpResourceSupply) return false; if (!cmpResourceSupply.GetKillBeforeGather()) return false; return this.TargetIsAlive(ent); }; /** * Returns the entity ID of the nearest resource supply where the given * filter returns true, or undefined if none can be found. * if position (as a vector2D) is given, the nearest is computed versus this position. * TODO: extend this to exclude resources that already have lots of * gatherers. */ UnitAI.prototype.FindNearbyResource = function(filter, position) { let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || cmpOwnership.GetOwner() == INVALID_PLAYER) return undefined; let owner = cmpOwnership.GetOwner(); // We accept resources owned by Gaia or any player let players = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetAllPlayers(); let range = 64; // TODO: what's a sensible number? let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); let pos = position; if (!pos) { let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) pos = cmpPosition.GetPosition2D(); } let nearby = cmpRangeManager.ExecuteQueryAroundPos(pos, 0, range, players, IID_ResourceSupply); return nearby.find(ent => { if (!this.CanGather(ent) || !this.CheckTargetVisible(ent)) return false; let cmpResourceSupply = Engine.QueryInterface(ent, IID_ResourceSupply); let type = cmpResourceSupply.GetType(); let amount = cmpResourceSupply.GetCurrentAmount(); let template = cmpTemplateManager.GetCurrentTemplateName(ent); // Remove "resource|" prefix from template names, if present. if (template.indexOf("resource|") != -1) template = template.slice(9); return amount > 0 && cmpResourceSupply.IsAvailable(owner, this.entity) && filter(ent, type, template); }); }; /** * Returns the entity ID of the nearest resource dropsite that accepts * the given type, or undefined if none can be found. */ UnitAI.prototype.FindNearestDropsite = function(genericType) { let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || cmpOwnership.GetOwner() == INVALID_PLAYER) return undefined; let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return undefined; let pos = cmpPosition.GetPosition2D(); let bestDropsite; let bestDist = Infinity; // Maximum distance a point on an obstruction can be from the center of the obstruction. let maxDifference = 40; // Find dropsites owned by this unit's player or allied ones if allowed. let owner = cmpOwnership.GetOwner(); let cmpPlayer = QueryOwnerInterface(this.entity); let players = cmpPlayer && cmpPlayer.HasSharedDropsites() ? cmpPlayer.GetMutualAllies() : [owner]; let nearbyDropsites = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).ExecuteQuery(this.entity, 0, -1, players, IID_ResourceDropsite); let isShip = Engine.QueryInterface(this.entity, IID_Identity).HasClass("Ship"); let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); for (let dropsite of nearbyDropsites) { // Ships are unable to reach land dropsites and shouldn't attempt to do so. if (isShip && !Engine.QueryInterface(dropsite, IID_Identity).HasClass("Naval")) continue; let cmpResourceDropsite = Engine.QueryInterface(dropsite, IID_ResourceDropsite); if (!cmpResourceDropsite.AcceptsType(genericType) || !this.CheckTargetVisible(dropsite)) continue; if (Engine.QueryInterface(dropsite, IID_Ownership).GetOwner() != owner && !cmpResourceDropsite.IsShared()) continue; // The range manager sorts entities by the distance to their center, // but we want the distance to the point where resources will be dropped off. let dist = cmpObstructionManager.DistanceToPoint(dropsite, pos.x, pos.y); if (dist == -1) continue; if (dist < bestDist) { bestDropsite = dropsite; bestDist = dist; } else if (dist > bestDist + maxDifference) break; } return bestDropsite; }; /** * Returns the entity ID of the nearest building that needs to be constructed, * or undefined if none can be found close enough. */ UnitAI.prototype.FindNearbyFoundation = function() { var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || cmpOwnership.GetOwner() == INVALID_PLAYER) return undefined; // Find buildings owned by this unit's player var players = [cmpOwnership.GetOwner()]; var range = 64; // TODO: what's a sensible number? var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); var nearby = cmpRangeManager.ExecuteQuery(this.entity, 0, range, players, IID_Foundation); // Skip foundations that are already complete. (This matters since // we process the ConstructionFinished message before the foundation // we're working on has been deleted.) return nearby.find(ent => !Engine.QueryInterface(ent, IID_Foundation).IsFinished()); }; /** * Play a sound appropriate to the current entity. */ UnitAI.prototype.PlaySound = function(name) { // If we're a formation controller, use the sounds from our first member if (this.IsFormationController()) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); var member = cmpFormation.GetPrimaryMember(); if (member) PlaySound(name, member); } else { // Otherwise use our own sounds PlaySound(name, this.entity); } }; /* * Set a visualActor animation variant. * By changing the animation variant, you can change animations based on unitAI state. * If there are no specific variants or the variant doesn't exist in the actor, * the actor fallbacks to any existing animation. * @param type if present, switch to a specific animation variant. */ UnitAI.prototype.SetAnimationVariant = function(type) { let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (!cmpVisual) return; cmpVisual.SetVariant("animationVariant", type); return; }; /* * Reset the animation variant to default behavior * Default behavior is to pick a resource-carrying variant if resources are being carried. * Otherwise pick nothing in particular. */ UnitAI.prototype.SetDefaultAnimationVariant = function() { let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (!cmpResourceGatherer) { this.SetAnimationVariant(""); return; } let type = cmpResourceGatherer.GetLastCarriedType(); if (type) { let typename = "carry_" + type.generic; // Special case for meat if (type.specific == "meat") typename = "carry_" + type.specific; this.SetAnimationVariant(typename); return; } this.SetAnimationVariant(""); }; UnitAI.prototype.ResetAnimation = function() { let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (!cmpVisual) return; cmpVisual.SelectAnimation("idle", false, 1.0); }; UnitAI.prototype.SelectAnimation = function(name, once = false, speed = 1.0) { let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (!cmpVisual) return; cmpVisual.SelectAnimation(name, once, speed); }; UnitAI.prototype.SetAnimationSync = function(actiontime, repeattime) { var cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (!cmpVisual) return; cmpVisual.SetAnimationSyncRepeat(repeattime); cmpVisual.SetAnimationSyncOffset(actiontime); }; UnitAI.prototype.StopMoving = function() { var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); cmpUnitMotion.StopMoving(); }; /** * Generic dispatcher for other MoveTo functions. * @param iid - Interface ID (optional) implementing GetRange * @param type - Range type for the interface call * @returns whether the move succeeded or failed. */ UnitAI.prototype.MoveTo = function(data, iid, type) { if (data.target) { if (data.min || data.max) return this.MoveToTargetRangeExplicit(data.target, data.min || -1, data.max || -1); else if (!iid) return this.MoveToTarget(data.target); return this.MoveToTargetRange(data.target, iid, type); } else if (data.min || data.max) return this.MoveToPointRange(data.x, data.z, data.min || -1, data.max || -1); return this.MoveToPoint(data.x, data.z); }; UnitAI.prototype.MoveToPoint = function(x, z) { var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.MoveToPointRange(x, z, 0, 0); // For point goals, allow a max range of 0. }; UnitAI.prototype.MoveToPointRange = function(x, z, rangeMin, rangeMax) { var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.MoveToPointRange(x, z, rangeMin, rangeMax); }; UnitAI.prototype.MoveToTarget = function(target) { if (!this.CheckTargetVisible(target)) return false; var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.MoveToTargetRange(target, 0, 1); }; UnitAI.prototype.MoveToTargetRange = function(target, iid, type) { if (!this.CheckTargetVisible(target) || this.IsTurret()) return false; var cmpRanged = Engine.QueryInterface(this.entity, iid); if (!cmpRanged) return false; var range = cmpRanged.GetRange(type); var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); }; /** * Move unit so we hope the target is in the attack range * for melee attacks, this goes straight to the default range checks * for ranged attacks, the parabolic range is used */ UnitAI.prototype.MoveToTargetAttackRange = function(target, type) { // for formation members, the formation will take care of the range check if (this.IsFormationMember()) { let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); if (cmpFormationUnitAI && cmpFormationUnitAI.IsAttackingAsFormation()) return false; } let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) target = cmpFormation.GetClosestMember(this.entity); if (type != "Ranged") return this.MoveToTargetRange(target, IID_Attack, type); if (!this.CheckTargetVisible(target)) return false; let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); let range = cmpAttack.GetRange(type); let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!thisCmpPosition.IsInWorld()) return false; let s = thisCmpPosition.GetPosition(); let targetCmpPosition = Engine.QueryInterface(target, IID_Position); if (!targetCmpPosition.IsInWorld()) return false; let t = targetCmpPosition.GetPosition(); // h is positive when I'm higher than the target let h = s.y - t.y + range.elevationBonus; let parabolicMaxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h); // No negative roots please if (h <= -range.max / 2) // return false? Or hope you come close enough? parabolicMaxRange = 0; // The parabole changes while walking so be cautious: let guessedMaxRange = parabolicMaxRange > range.max ? (range.max + parabolicMaxRange) / 2 : parabolicMaxRange; let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange); }; UnitAI.prototype.MoveToTargetRangeExplicit = function(target, min, max) { if (!this.CheckTargetVisible(target)) return false; var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.MoveToTargetRange(target, min, max); }; UnitAI.prototype.MoveToGarrisonRange = function(target) { if (!this.CheckTargetVisible(target)) return false; var cmpGarrisonHolder = Engine.QueryInterface(target, IID_GarrisonHolder); if (!cmpGarrisonHolder) return false; var range = cmpGarrisonHolder.GetLoadingRange(); var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); }; /** * Generic dispatcher for other Check...Range functions. * @param iid - Interface ID (optional) implementing GetRange * @param type - Range type for the interface call */ UnitAI.prototype.CheckRange = function(data, iid, type) { if (data.target) { if (data.min || data.max) return this.CheckTargetRangeExplicit(data.target, data.min || -1, data.max || -1); else if (!iid) return this.CheckTargetRangeExplicit(data.target, 0, 1); return this.CheckTargetRange(data.target, iid, type); } else if (data.min || data.max) return this.CheckPointRangeExplicit(data.x, data.z, data.min || -1, data.max || -1); return this.CheckPointRangeExplicit(data.x, data.z, 0, 0); }; UnitAI.prototype.CheckPointRangeExplicit = function(x, z, min, max) { let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); return cmpObstructionManager.IsInPointRange(this.entity, x, z, min, max, false); }; UnitAI.prototype.CheckTargetRange = function(target, iid, type) { var cmpRanged = Engine.QueryInterface(this.entity, iid); if (!cmpRanged) return false; var range = cmpRanged.GetRange(type); let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false); }; /** * Check if the target is inside the attack range * For melee attacks, this goes straigt to the regular range calculation * For ranged attacks, the parabolic formula is used to accout for bigger ranges * when the target is lower, and smaller ranges when the target is higher */ UnitAI.prototype.CheckTargetAttackRange = function(target, type) { // for formation members, the formation will take care of the range check if (this.IsFormationMember()) { let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); if (cmpFormationUnitAI && cmpFormationUnitAI.IsAttackingAsFormation() && cmpFormationUnitAI.order.data.target == target) return true; } let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) target = cmpFormation.GetClosestMember(this.entity); if (type != "Ranged") return this.CheckTargetRange(target, IID_Attack, type); let targetCmpPosition = Engine.QueryInterface(target, IID_Position); if (!targetCmpPosition || !targetCmpPosition.IsInWorld()) return false; let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); let range = cmpAttack.GetRange(type); let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!thisCmpPosition.IsInWorld()) return false; let s = thisCmpPosition.GetPosition(); let t = targetCmpPosition.GetPosition(); let h = s.y - t.y + range.elevationBonus; let maxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h); if (maxRange < 0) return false; let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, maxRange, false); }; UnitAI.prototype.CheckTargetRangeExplicit = function(target, min, max) { let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); return cmpObstructionManager.IsInTargetRange(this.entity, target, min, max, false); }; UnitAI.prototype.CheckGarrisonRange = function(target) { var cmpGarrisonHolder = Engine.QueryInterface(target, IID_GarrisonHolder); if (!cmpGarrisonHolder) return false; var range = cmpGarrisonHolder.GetLoadingRange(); var cmpObstruction = Engine.QueryInterface(this.entity, IID_Obstruction); if (cmpObstruction) range.max += cmpObstruction.GetUnitRadius()*1.5; // multiply by something larger than sqrt(2) let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false); }; /** * Returns true if the target entity is visible through the FoW/SoD. */ UnitAI.prototype.CheckTargetVisible = function(target) { var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership) return false; var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (!cmpRangeManager) return false; // Entities that are hidden and miraged are considered visible var cmpFogging = Engine.QueryInterface(target, IID_Fogging); if (cmpFogging && cmpFogging.IsMiraged(cmpOwnership.GetOwner())) return true; if (cmpRangeManager.GetLosVisibility(target, cmpOwnership.GetOwner()) == "hidden") return false; // Either visible directly, or visible in fog return true; }; /** * How close to our goal do we consider it's OK to stop if the goal appears unreachable. * Currently 3 terrain tiles as that's relatively close but helps pathfinding. */ UnitAI.prototype.DefaultRelaxedMaxRange = 12; /** * @returns true if the unit is in the relaxed-range from the target. */ UnitAI.prototype.RelaxedMaxRangeCheck = function(data, relaxedRange) { if (!data.relaxed) return false; let ndata = data; ndata.min = 0; ndata.max = relaxedRange; return this.CheckRange(ndata); }; /** * Let an entity face its target. * @param {number} target - The entity-ID of the target. */ UnitAI.prototype.FaceTowardsTarget = function(target) { let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) return; let targetPosition = cmpTargetPosition.GetPosition2D(); // Use cmpUnitMotion for units that support that, otherwise try cmpPosition (e.g. turrets) let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion) { cmpUnitMotion.FaceTowardsPoint(targetPosition.x, targetPosition.y); return; } let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) cmpPosition.TurnTo(cmpPosition.GetPosition2D().angleTo(targetPosition)); }; UnitAI.prototype.CheckTargetDistanceFromHeldPosition = function(target, iid, type) { var cmpRanged = Engine.QueryInterface(this.entity, iid); var range = iid !== IID_Attack ? cmpRanged.GetRange() : cmpRanged.GetRange(type); var cmpPosition = Engine.QueryInterface(target, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return false; var cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) return false; var halfvision = cmpVision.GetRange() / 2; var pos = cmpPosition.GetPosition(); var heldPosition = this.heldPosition; if (heldPosition === undefined) heldPosition = { "x": pos.x, "z": pos.z }; return Math.euclidDistance2D(pos.x, pos.z, heldPosition.x, heldPosition.z) < halfvision + range.max; }; UnitAI.prototype.CheckTargetIsInVisionRange = function(target) { var cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) return false; var range = cmpVision.GetRange(); var distance = DistanceBetweenEntities(this.entity, target); return distance < range; }; UnitAI.prototype.GetBestAttackAgainst = function(target, allowCapture) { var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return undefined; return cmpAttack.GetBestAttackAgainst(target, allowCapture); }; /** * Try to find one of the given entities which can be attacked, * and start attacking it. * Returns true if it found something to attack. */ UnitAI.prototype.AttackVisibleEntity = function(ents) { var target = ents.find(target => this.CanAttack(target)); if (!target) return false; this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": true }); return true; }; /** * Try to find one of the given entities which can be attacked * and which is close to the hold position, and start attacking it. * Returns true if it found something to attack. */ UnitAI.prototype.AttackEntityInZone = function(ents) { var target = ents.find(target => this.CanAttack(target) && this.CheckTargetDistanceFromHeldPosition(target, IID_Attack, this.GetBestAttackAgainst(target, true)) && (this.GetStance().respondChaseBeyondVision || this.CheckTargetIsInVisionRange(target)) ); if (!target) return false; this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": true }); return true; }; /** * Try to respond appropriately given our current stance, * given a list of entities that match our stance's target criteria. * Returns true if it responded. */ UnitAI.prototype.RespondToTargetedEntities = function(ents) { if (!ents.length) return false; if (this.GetStance().respondChase) return this.AttackVisibleEntity(ents); if (this.GetStance().respondStandGround) return this.AttackVisibleEntity(ents); if (this.GetStance().respondHoldGround) return this.AttackEntityInZone(ents); if (this.GetStance().respondFlee) { this.PushOrderFront("Flee", { "target": ents[0], "force": false }); return true; } return false; }; /** * Try to respond to healable entities. * Returns true if it responded. */ UnitAI.prototype.RespondToHealableEntities = function(ents) { var ent = ents.find(ent => this.CanHeal(ent)); if (!ent) return false; this.PushOrderFront("Heal", { "target": ent, "force": false }); return true; }; /** * Returns true if we should stop following the target entity. */ UnitAI.prototype.ShouldAbandonChase = function(target, force, iid, type) { // Forced orders shouldn't be interrupted. if (force) return false; // If we are guarding/escorting, don't abandon as long as the guarded unit is in target range of the attacker if (this.isGuardOf) { var cmpUnitAI = Engine.QueryInterface(target, IID_UnitAI); var cmpAttack = Engine.QueryInterface(target, IID_Attack); if (cmpUnitAI && cmpAttack && cmpAttack.GetAttackTypes().some(type => cmpUnitAI.CheckTargetAttackRange(this.isGuardOf, type))) return false; } // Stop if we're in hold-ground mode and it's too far from the holding point if (this.GetStance().respondHoldGround) { if (!this.CheckTargetDistanceFromHeldPosition(target, iid, type)) return true; } // Stop if it's left our vision range, unless we're especially persistent if (!this.GetStance().respondChaseBeyondVision) { if (!this.CheckTargetIsInVisionRange(target)) return true; } // (Note that CCmpUnitMotion will detect if the target is lost in FoW, // and will continue moving to its last seen position and then stop) return false; }; /* * Returns whether we should chase the targeted entity, * given our current stance. */ UnitAI.prototype.ShouldChaseTargetedEntity = function(target, force) { if (this.IsTurret()) return false; if (this.GetStance().respondChase) return true; // If we are guarding/escorting, chase at least as long as the guarded unit is in target range of the attacker if (this.isGuardOf) { let cmpUnitAI = Engine.QueryInterface(target, IID_UnitAI); let cmpAttack = Engine.QueryInterface(target, IID_Attack); if (cmpUnitAI && cmpAttack && cmpAttack.GetAttackTypes().some(type => cmpUnitAI.CheckTargetAttackRange(this.isGuardOf, type))) return true; } if (force) return true; return false; }; //// External interface functions //// UnitAI.prototype.SetFormationController = function(ent) { this.formationController = ent; // Set obstruction group, so we can walk through members // of our own formation (or ourself if not in formation) var cmpObstruction = Engine.QueryInterface(this.entity, IID_Obstruction); if (cmpObstruction) { if (ent == INVALID_ENTITY) cmpObstruction.SetControlGroup(this.entity); else cmpObstruction.SetControlGroup(ent); } // If we were removed from a formation, let the FSM switch back to INDIVIDUAL if (ent == INVALID_ENTITY) this.UnitFsm.ProcessMessage(this, { "type": "FormationLeave" }); }; UnitAI.prototype.GetFormationController = function() { return this.formationController; }; UnitAI.prototype.GetFormationTemplate = function() { return Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager).GetCurrentTemplateName(this.formationController) || "special/formations/null"; }; UnitAI.prototype.MoveIntoFormation = function(cmd) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (!cmpFormation) return; var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return; var pos = cmpPosition.GetPosition(); // Add new order to move into formation at the current position this.PushOrderFront("MoveIntoFormation", { "x": pos.x, "z": pos.z, "force": true }); }; UnitAI.prototype.GetTargetPositions = function() { var targetPositions = []; for (var i = 0; i < this.orderQueue.length; ++i) { var order = this.orderQueue[i]; switch (order.type) { case "Walk": case "WalkAndFight": case "WalkToPointRange": case "MoveIntoFormation": case "GatherNearPosition": case "Patrol": targetPositions.push(new Vector2D(order.data.x, order.data.z)); break; // and continue the loop case "WalkToTarget": case "WalkToTargetRange": // This doesn't move to the target (just into range), but a later order will. case "Guard": case "Flee": case "LeaveFoundation": case "Attack": case "Heal": case "Gather": case "ReturnResource": case "Repair": case "Garrison": // Find the target unit's position var cmpTargetPosition = Engine.QueryInterface(order.data.target, IID_Position); if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) return targetPositions; targetPositions.push(cmpTargetPosition.GetPosition2D()); return targetPositions; case "Stop": return []; default: error("GetTargetPositions: Unrecognised order type '"+order.type+"'"); return []; } } return targetPositions; }; /** * Returns the estimated distance that this unit will travel before either * finishing all of its orders, or reaching a non-walk target (attack, gather, etc). * Intended for Formation to switch to column layout on long walks. */ UnitAI.prototype.ComputeWalkingDistance = function() { var distance = 0; var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return 0; // Keep track of the position at the start of each order var pos = cmpPosition.GetPosition2D(); var targetPositions = this.GetTargetPositions(); for (var i = 0; i < targetPositions.length; ++i) { distance += pos.distanceTo(targetPositions[i]); // Remember this as the start position for the next order pos = targetPositions[i]; } // Return the total distance to the end of the order queue return distance; }; UnitAI.prototype.AddOrder = function(type, data, queued) { if (this.expectedRoute) this.expectedRoute = undefined; if (queued) this.PushOrder(type, data); else { // May happen if an order arrives on the same turn the unit is garrisoned // in that case, just forget the order as this will lead to an infinite loop if (this.IsGarrisoned() && !this.IsTurret() && type != "Ungarrison") return; this.ReplaceOrder(type, data); } }; /** * Adds guard/escort order to the queue, forced by the player. */ UnitAI.prototype.Guard = function(target, queued) { if (!this.CanGuard()) { this.WalkToTarget(target, queued); return; } // if we already had an old guard order, do nothing if the target is the same // and the order is running, otherwise remove the previous order if (this.isGuardOf) { if (this.isGuardOf == target && this.order && this.order.type == "Guard") return; else this.RemoveGuard(); } this.AddOrder("Guard", { "target": target, "force": false }, queued); }; UnitAI.prototype.AddGuard = function(target) { if (!this.CanGuard()) return false; var cmpGuard = Engine.QueryInterface(target, IID_Guard); if (!cmpGuard) return false; // Do not allow to guard a unit already guarding var cmpUnitAI = Engine.QueryInterface(target, IID_UnitAI); if (cmpUnitAI && cmpUnitAI.IsGuardOf()) return false; this.isGuardOf = target; this.guardRange = cmpGuard.GetRange(this.entity); cmpGuard.AddGuard(this.entity); return true; }; UnitAI.prototype.RemoveGuard = function() { if (!this.isGuardOf) return; let cmpGuard = Engine.QueryInterface(this.isGuardOf, IID_Guard); if (cmpGuard) cmpGuard.RemoveGuard(this.entity); this.guardRange = undefined; this.isGuardOf = undefined; if (!this.order) return; if (this.order.type == "Guard") this.UnitFsm.ProcessMessage(this, { "type": "RemoveGuard" }); else for (let i = 1; i < this.orderQueue.length; ++i) if (this.orderQueue[i].type == "Guard") this.orderQueue.splice(i, 1); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); }; UnitAI.prototype.IsGuardOf = function() { return this.isGuardOf; }; UnitAI.prototype.SetGuardOf = function(entity) { // entity may be undefined this.isGuardOf = entity; }; UnitAI.prototype.CanGuard = function() { // Formation controllers should always respond to commands // (then the individual units can make up their own minds) if (this.IsFormationController()) return true; // Do not let a unit already guarded to guard. This would work in principle, // but would clutter the gui with too much buttons to take all cases into account var cmpGuard = Engine.QueryInterface(this.entity, IID_Guard); if (cmpGuard && cmpGuard.GetEntities().length) return false; return this.template.CanGuard == "true"; }; UnitAI.prototype.CanPatrol = function() { // Formation controllers should always respond to commands // (then the individual units can make up their own minds) return this.IsFormationController() || this.template.CanPatrol == "true"; }; /** * Adds walk order to queue, forced by the player. */ UnitAI.prototype.Walk = function(x, z, queued) { if (this.expectedRoute && queued) this.expectedRoute.push({ "x": x, "z": z }); else this.AddOrder("Walk", { "x": x, "z": z, "force": true }, queued); }; /** * Adds walk to point range order to queue, forced by the player. */ UnitAI.prototype.WalkToPointRange = function(x, z, min, max, queued) { this.AddOrder("Walk", { "x": x, "z": z, "min": min, "max": max, "force": true }, queued); }; /** * Adds stop order to queue, forced by the player. */ UnitAI.prototype.Stop = function(queued) { this.AddOrder("Stop", { "force": true }, queued); }; /** * Adds walk-to-target order to queue, this only occurs in response * to a player order, and so is forced. */ UnitAI.prototype.WalkToTarget = function(target, queued) { this.AddOrder("WalkToTarget", { "target": target, "force": true }, queued); }; /** * Adds walk-and-fight order to queue, this only occurs in response * to a player order, and so is forced. * If targetClasses is given, only entities matching the targetClasses can be attacked. */ UnitAI.prototype.WalkAndFight = function(x, z, targetClasses, allowCapture = true, queued = false) { this.AddOrder("WalkAndFight", { "x": x, "z": z, "targetClasses": targetClasses, "allowCapture": allowCapture, "force": true }, queued); }; UnitAI.prototype.Patrol = function(x, z, targetClasses, allowCapture = true, queued = false) { if (!this.CanPatrol()) { this.Walk(x, z, queued); return; } this.AddOrder("Patrol", { "x": x, "z": z, "targetClasses": targetClasses, "allowCapture": allowCapture, "force": true }, queued); }; /** * Adds leave foundation order to queue, treated as forced. */ UnitAI.prototype.LeaveFoundation = function(target) { // If we're already being told to leave a foundation, then // ignore this new request so we don't end up being too indecisive // to ever actually move anywhere // Ignore also the request if we are packing if (this.order && (this.order.type == "LeaveFoundation" || (this.order.type == "Flee" && this.order.data.target == target) || this.IsPacking())) return; this.PushOrderFront("LeaveFoundation", { "target": target, "force": true }); }; /** * Adds attack order to the queue, forced by the player. */ UnitAI.prototype.Attack = function(target, allowCapture = true, queued = false) { if (!this.CanAttack(target)) { // We don't want to let healers walk to the target unit so they can be easily killed. // Instead we just let them get into healing range. if (this.IsHealer()) this.MoveToTargetRange(target, IID_Heal); else this.WalkToTarget(target, queued); return; } let order = { "target": target, "force": true, "allowCapture": allowCapture, }; this.RememberTargetPosition(order); this.AddOrder("Attack", order, queued); }; /** * Adds garrison order to the queue, forced by the player. */ UnitAI.prototype.Garrison = function(target, queued) { if (target == this.entity) return; if (!this.CanGarrison(target)) { this.WalkToTarget(target, queued); return; } this.AddOrder("Garrison", { "target": target, "force": true }, queued); }; /** * Adds ungarrison order to the queue. */ UnitAI.prototype.Ungarrison = function() { if (this.IsGarrisoned()) this.AddOrder("Ungarrison", null, false); }; /** * Adds a garrison order for units that are already garrisoned in the garrison holder. */ UnitAI.prototype.Autogarrison = function(target) { this.isGarrisoned = true; this.PushOrderFront("Garrison", { "target": target }); }; /** * Adds gather order to the queue, forced by the player * until the target is reached */ UnitAI.prototype.Gather = function(target, queued) { this.PerformGather(target, queued, true); }; /** * Internal function to abstract the force parameter. */ UnitAI.prototype.PerformGather = function(target, queued, force) { if (!this.CanGather(target)) { this.WalkToTarget(target, queued); return; } // Save the resource type now, so if the resource gets destroyed // before we process the order then we still know what resource // type to look for more of var type; var cmpResourceSupply = QueryMiragedInterface(target, IID_ResourceSupply); if (cmpResourceSupply) type = cmpResourceSupply.GetType(); else error("CanGather allowed gathering from invalid entity"); // Also save the target entity's template, so that if it's an animal, // we won't go from hunting slow safe animals to dangerous fast ones var cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); var template = cmpTemplateManager.GetCurrentTemplateName(target); // Remove "resource|" prefix from template name, if present. if (template.indexOf("resource|") != -1) template = template.slice(9); let order = { "target": target, "type": type, "template": template, "force": force, }; this.RememberTargetPosition(order); order.initPos = order.lastPos; this.AddOrder("Gather", order, queued); }; /** * Adds gather-near-position order to the queue, not forced, so it can be * interrupted by attacks. */ UnitAI.prototype.GatherNearPosition = function(x, z, type, template, queued) { // Remove "resource|" prefix from template name, if present. if (template.indexOf("resource|") != -1) template = template.slice(9); if (this.IsFormationController() || Engine.QueryInterface(this.entity, IID_ResourceGatherer)) this.AddOrder("GatherNearPosition", { "type": type, "template": template, "x": x, "z": z, "force": false }, queued); else this.AddOrder("Walk", { "x": x, "z": z, "force": false }, queued); }; /** * Adds heal order to the queue, forced by the player. */ UnitAI.prototype.Heal = function(target, queued) { if (!this.CanHeal(target)) { this.WalkToTarget(target, queued); return; } this.AddOrder("Heal", { "target": target, "force": true }, queued); }; /** * Adds return resource order to the queue, forced by the player. */ UnitAI.prototype.ReturnResource = function(target, queued) { if (!this.CanReturnResource(target, true)) { this.WalkToTarget(target, queued); return; } this.AddOrder("ReturnResource", { "target": target, "force": true }, queued); }; /** * Adds trade order to the queue. Either walk to the first market, or * start a new route. Not forced, so it can be interrupted by attacks. * The possible route may be given directly as a SetupTradeRoute argument * if coming from a RallyPoint, or through this.expectedRoute if a user command. */ UnitAI.prototype.SetupTradeRoute = function(target, source, route, queued) { if (!this.CanTrade(target)) { this.WalkToTarget(target, queued); return; } // AI has currently no access to BackToWork let cmpPlayer = QueryOwnerInterface(this.entity); if (cmpPlayer && cmpPlayer.IsAI() && !this.IsFormationController() && this.workOrders.length && this.workOrders[0].type == "Trade") { let cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); if (cmpTrader.HasBothMarkets() && (cmpTrader.GetFirstMarket() == target && cmpTrader.GetSecondMarket() == source || cmpTrader.GetFirstMarket() == source && cmpTrader.GetSecondMarket() == target)) { this.BackToWork(); return; } } var marketsChanged = this.SetTargetMarket(target, source); if (!marketsChanged) return; var cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); if (cmpTrader.HasBothMarkets()) { let data = { "target": cmpTrader.GetFirstMarket(), "route": route, "force": false }; if (this.expectedRoute) { if (!route && this.expectedRoute.length) data.route = this.expectedRoute.slice(); this.expectedRoute = undefined; } if (this.IsFormationController()) { this.CallMemberFunction("AddOrder", ["Trade", data, queued]); let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (cmpFormation) cmpFormation.Disband(); } else this.AddOrder("Trade", data, queued); } else { if (this.IsFormationController()) this.CallMemberFunction("WalkToTarget", [cmpTrader.GetFirstMarket(), queued]); else this.WalkToTarget(cmpTrader.GetFirstMarket(), queued); this.expectedRoute = []; } }; UnitAI.prototype.SetTargetMarket = function(target, source) { var cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); if (!cmpTrader) return false; var marketsChanged = cmpTrader.SetTargetMarket(target, source); if (this.IsFormationController()) this.CallMemberFunction("SetTargetMarket", [target, source]); return marketsChanged; }; UnitAI.prototype.SwitchMarketOrder = function(oldMarket, newMarket) { if (this.order && this.order.data && this.order.data.target && this.order.data.target == oldMarket) this.order.data.target = newMarket; }; UnitAI.prototype.MoveToMarket = function(targetMarket) { if (this.waypoints && this.waypoints.length > 1) { let point = this.waypoints.pop(); return this.MoveToPoint(point.x, point.z) || this.MoveToMarket(targetMarket); } this.waypoints = undefined; return this.MoveToTargetRange(targetMarket, IID_Trader); }; UnitAI.prototype.PerformTradeAndMoveToNextMarket = function(currentMarket) { if (!this.CanTrade(currentMarket)) { this.StopTrading(); return; } if (!this.CheckTargetRange(currentMarket, IID_Trader)) { if (!this.MoveToMarket(currentMarket)) // If the current market is not reached try again this.StopTrading(); return; } let cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); let nextMarket = cmpTrader.PerformTrade(currentMarket); let amount = cmpTrader.GetGoods().amount; if (!nextMarket || !amount || !amount.traderGain) { this.StopTrading(); return; } this.order.data.target = nextMarket; if (this.order.data.route && this.order.data.route.length) { this.waypoints = this.order.data.route.slice(); if (this.order.data.target == cmpTrader.GetSecondMarket()) this.waypoints.reverse(); this.waypoints.unshift(null); // additionnal dummy point for the market } if (this.MoveToMarket(nextMarket)) // We've started walking to the next market this.SetNextState("APPROACHINGMARKET"); else this.StopTrading(); }; UnitAI.prototype.MarketRemoved = function(market) { if (this.order && this.order.data && this.order.data.target && this.order.data.target == market) this.UnitFsm.ProcessMessage(this, { "type": "TradingCanceled", "market": market }); }; UnitAI.prototype.StopTrading = function() { this.StopMoving(); this.FinishOrder(); var cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); cmpTrader.StopTrading(); }; /** * Adds repair/build order to the queue, forced by the player * until the target is reached */ UnitAI.prototype.Repair = function(target, autocontinue, queued) { if (!this.CanRepair(target)) { this.WalkToTarget(target, queued); return; } this.AddOrder("Repair", { "target": target, "autocontinue": autocontinue, "force": true }, queued); }; /** * Adds flee order to the queue, not forced, so it can be * interrupted by attacks. */ UnitAI.prototype.Flee = function(target, queued) { this.AddOrder("Flee", { "target": target, "force": false }, queued); }; /** * Adds cheer order to the queue. Forced so it won't be interrupted by attacks. */ UnitAI.prototype.Cheer = function() { this.AddOrder("Cheering", { "force": true }, false); }; UnitAI.prototype.Pack = function(queued) { // Check that we can pack if (this.CanPack()) this.AddOrder("Pack", { "force": true }, queued); }; UnitAI.prototype.Unpack = function(queued) { // Check that we can unpack if (this.CanUnpack()) this.AddOrder("Unpack", { "force": true }, queued); }; UnitAI.prototype.CancelPack = function(queued) { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); if (cmpPack && cmpPack.IsPacking() && !cmpPack.IsPacked()) this.AddOrder("CancelPack", { "force": true }, queued); }; UnitAI.prototype.CancelUnpack = function(queued) { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); if (cmpPack && cmpPack.IsPacking() && cmpPack.IsPacked()) this.AddOrder("CancelUnpack", { "force": true }, queued); }; UnitAI.prototype.SetStance = function(stance) { if (g_Stances[stance]) { this.stance = stance; Engine.PostMessage(this.entity, MT_UnitStanceChanged, { "to": this.stance }); } else error("UnitAI: Setting to invalid stance '"+stance+"'"); }; UnitAI.prototype.SwitchToStance = function(stance) { var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return; var pos = cmpPosition.GetPosition(); this.SetHeldPosition(pos.x, pos.z); this.SetStance(stance); // Stop moving if switching to stand ground // TODO: Also stop existing orders in a sensible way if (stance == "standground") this.StopMoving(); // Reset the range queries, since the range depends on stance. this.SetupRangeQueries(); }; UnitAI.prototype.SetTurretStance = function() { this.previousStance = undefined; if (this.GetStance().respondStandGround) return; for (let stance in g_Stances) { if (!g_Stances[stance].respondStandGround) continue; this.previousStance = this.GetStanceName(); this.SwitchToStance(stance); return; } }; UnitAI.prototype.ResetTurretStance = function() { if (!this.previousStance) return; this.SwitchToStance(this.previousStance); this.previousStance = undefined; }; /** * Resets losRangeQuery, and if there are some targets in range that we can * attack then we start attacking and this returns true; otherwise, returns false. */ UnitAI.prototype.FindNewTargets = function() { if (!this.losRangeQuery) return false; if (!this.GetStance().targetVisibleEnemies) return false; var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); return this.AttackEntitiesByPreference(cmpRangeManager.ResetActiveQuery(this.losRangeQuery)); }; UnitAI.prototype.FindWalkAndFightTargets = function() { if (this.IsFormationController()) { var cmpUnitAI; var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); for (var ent of cmpFormation.members) { if (!(cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI))) continue; var targets = cmpUnitAI.GetTargetsFromUnit(); for (var targ of targets) { if (!cmpUnitAI.CanAttack(targ)) continue; if (this.order.data.targetClasses) { var cmpIdentity = Engine.QueryInterface(targ, IID_Identity); var targetClasses = this.order.data.targetClasses; if (targetClasses.attack && cmpIdentity && !MatchesClassList(cmpIdentity.GetClassesList(), targetClasses.attack)) continue; if (targetClasses.avoid && cmpIdentity && MatchesClassList(cmpIdentity.GetClassesList(), targetClasses.avoid)) continue; // Only used by the AIs to prevent some choices of targets if (targetClasses.vetoEntities && targetClasses.vetoEntities[targ]) continue; } this.PushOrderFront("Attack", { "target": targ, "force": false, "allowCapture": this.order.data.allowCapture }); return true; } } return false; } var targets = this.GetTargetsFromUnit(); for (var targ of targets) { if (!this.CanAttack(targ)) continue; if (this.order.data.targetClasses) { var cmpIdentity = Engine.QueryInterface(targ, IID_Identity); var targetClasses = this.order.data.targetClasses; if (cmpIdentity && targetClasses.attack && !MatchesClassList(cmpIdentity.GetClassesList(), targetClasses.attack)) continue; if (cmpIdentity && targetClasses.avoid && MatchesClassList(cmpIdentity.GetClassesList(), targetClasses.avoid)) continue; // Only used by the AIs to prevent some choices of targets if (targetClasses.vetoEntities && targetClasses.vetoEntities[targ]) continue; } this.PushOrderFront("Attack", { "target": targ, "force": false, "allowCapture": this.order.data.allowCapture }); return true; } // healers on a walk-and-fight order should heal injured units if (this.IsHealer()) return this.FindNewHealTargets(); return false; }; UnitAI.prototype.GetTargetsFromUnit = function() { if (!this.losRangeQuery) return []; if (!this.GetStance().targetVisibleEnemies) return []; var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return []; var attackfilter = function(e) { var cmpOwnership = Engine.QueryInterface(e, IID_Ownership); if (cmpOwnership && cmpOwnership.GetOwner() > 0) return true; var cmpUnitAI = Engine.QueryInterface(e, IID_UnitAI); return cmpUnitAI && (!cmpUnitAI.IsAnimal() || cmpUnitAI.IsDangerousAnimal()); }; var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); var entities = cmpRangeManager.ResetActiveQuery(this.losRangeQuery); var targets = entities.filter(function(v) { return cmpAttack.CanAttack(v) && attackfilter(v); }) .sort(function(a, b) { return cmpAttack.CompareEntitiesByPreference(a, b); }); return targets; }; /** * Resets losHealRangeQuery, and if there are some targets in range that we can heal * then we start healing and this returns true; otherwise, returns false. */ UnitAI.prototype.FindNewHealTargets = function() { if (!this.losHealRangeQuery) return false; var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); return this.RespondToHealableEntities(cmpRangeManager.ResetActiveQuery(this.losHealRangeQuery)); }; UnitAI.prototype.GetQueryRange = function(iid) { var ret = { "min": 0, "max": 0 }; if (this.GetStance().respondStandGround) { var cmpRanged = Engine.QueryInterface(this.entity, iid); if (!cmpRanged) return ret; var range = iid !== IID_Attack ? cmpRanged.GetRange() : cmpRanged.GetFullAttackRange(); var cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) return ret; ret.min = range.min; ret.max = Math.min(range.max, cmpVision.GetRange()); } else if (this.GetStance().respondChase) { var cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) return ret; var range = cmpVision.GetRange(); ret.max = range; } else if (this.GetStance().respondHoldGround) { var cmpRanged = Engine.QueryInterface(this.entity, iid); if (!cmpRanged) return ret; var range = iid !== IID_Attack ? cmpRanged.GetRange() : cmpRanged.GetFullAttackRange(); var cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) return ret; var vision = cmpVision.GetRange(); ret.max = Math.min(range.max + vision / 2, vision); } // We probably have stance 'passive' and we wouldn't have a range, // but as it is the default for healers we need to set it to something sane. else if (iid === IID_Heal) { var cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) return ret; var range = cmpVision.GetRange(); ret.max = range; } return ret; }; UnitAI.prototype.GetStance = function() { return g_Stances[this.stance]; }; UnitAI.prototype.GetSelectableStances = function() { if (this.IsTurret()) return []; return Object.keys(g_Stances).filter(key => g_Stances[key].selectable); }; UnitAI.prototype.GetStanceName = function() { return this.stance; }; /* * Make the unit walk at its normal pace. */ UnitAI.prototype.ResetSpeedMultiplier = function() { let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion) cmpUnitMotion.SetSpeedMultiplier(1); }; UnitAI.prototype.SetSpeedMultiplier = function(speed) { let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion) cmpUnitMotion.SetSpeedMultiplier(speed); }; /* * Remember the position of the target (in lastPos), if any, in case it disappears later * and we want to head to its last known position. * @param orderData - The order data to set this on. Defaults to this.order.data */ UnitAI.prototype.RememberTargetPosition = function(orderData) { if (!orderData) orderData = this.order.data; let cmpPosition = Engine.QueryInterface(orderData.target, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) orderData.lastPos = cmpPosition.GetPosition(); }; UnitAI.prototype.SetHeldPosition = function(x, z) { this.heldPosition = {"x": x, "z": z}; }; UnitAI.prototype.SetHeldPositionOnEntity = function(entity) { var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return; var pos = cmpPosition.GetPosition(); this.SetHeldPosition(pos.x, pos.z); }; UnitAI.prototype.GetHeldPosition = function() { return this.heldPosition; }; UnitAI.prototype.WalkToHeldPosition = function() { if (this.heldPosition) { this.AddOrder("Walk", { "x": this.heldPosition.x, "z": this.heldPosition.z, "force": false }, false); return true; } return false; }; //// Helper functions //// UnitAI.prototype.CanAttack = function(target) { // Formation controllers should always respond to commands // (then the individual units can make up their own minds) if (this.IsFormationController()) return true; let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); return cmpAttack && cmpAttack.CanAttack(target); }; UnitAI.prototype.CanGarrison = function(target) { // Formation controllers should always respond to commands // (then the individual units can make up their own minds) if (this.IsFormationController()) return true; var cmpGarrisonHolder = Engine.QueryInterface(target, IID_GarrisonHolder); if (!cmpGarrisonHolder) return false; // Verify that the target is owned by this entity's player or a mutual ally of this player var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || !(IsOwnedByPlayer(cmpOwnership.GetOwner(), target) || IsOwnedByMutualAllyOfPlayer(cmpOwnership.GetOwner(), target))) return false; // Don't let animals garrison for now // (If we want to support that, we'll need to change Order.Garrison so it // doesn't move the animal into an INVIDIDUAL.* state) if (this.IsAnimal()) return false; return true; }; UnitAI.prototype.CanGather = function(target) { if (this.IsTurret()) return false; // The target must be a valid resource supply, or the mirage of one. var cmpResourceSupply = QueryMiragedInterface(target, IID_ResourceSupply); if (!cmpResourceSupply) return false; // Formation controllers should always respond to commands // (then the individual units can make up their own minds) if (this.IsFormationController()) return true; // Verify that we're able to respond to Gather commands var cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (!cmpResourceGatherer) return false; // Verify that we can gather from this target if (!cmpResourceGatherer.GetTargetGatherRate(target)) return false; // No need to verify ownership as we should be able to gather from // a target regardless of ownership. // No need to call "cmpResourceSupply.IsAvailable()" either because that // would cause units to walk to full entities instead of choosing another one // nearby to gather from, which is undesirable. return true; }; UnitAI.prototype.CanHeal = function(target) { // Formation controllers should always respond to commands // (then the individual units can make up their own minds) if (this.IsFormationController()) return true; // Verify that we're able to respond to Heal commands var cmpHeal = Engine.QueryInterface(this.entity, IID_Heal); if (!cmpHeal) return false; // Verify that the target is alive if (!this.TargetIsAlive(target)) return false; // Verify that the target is owned by the same player as the entity or of an ally var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || !(IsOwnedByPlayer(cmpOwnership.GetOwner(), target) || IsOwnedByAllyOfPlayer(cmpOwnership.GetOwner(), target))) return false; // Verify that the target is not unhealable (or at max health) var cmpHealth = Engine.QueryInterface(target, IID_Health); if (!cmpHealth || cmpHealth.IsUnhealable()) return false; // Verify that the target has no unhealable class var cmpIdentity = Engine.QueryInterface(target, IID_Identity); if (!cmpIdentity) return false; if (MatchesClassList(cmpIdentity.GetClassesList(), cmpHeal.GetUnhealableClasses())) return false; // Verify that the target is a healable class if (MatchesClassList(cmpIdentity.GetClassesList(), cmpHeal.GetHealableClasses())) return true; return false; }; UnitAI.prototype.CanReturnResource = function(target, checkCarriedResource) { if (this.IsTurret()) return false; // Formation controllers should always respond to commands // (then the individual units can make up their own minds) if (this.IsFormationController()) return true; // Verify that we're able to respond to ReturnResource commands var cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (!cmpResourceGatherer) return false; // Verify that the target is a dropsite var cmpResourceDropsite = Engine.QueryInterface(target, IID_ResourceDropsite); if (!cmpResourceDropsite) return false; if (checkCarriedResource) { // Verify that we are carrying some resources, // and can return our current resource to this target var type = cmpResourceGatherer.GetMainCarryingType(); if (!type || !cmpResourceDropsite.AcceptsType(type)) return false; } // Verify that the dropsite is owned by this entity's player (or a mutual ally's if allowed) var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (cmpOwnership && IsOwnedByPlayer(cmpOwnership.GetOwner(), target)) return true; var cmpPlayer = QueryOwnerInterface(this.entity); return cmpPlayer && cmpPlayer.HasSharedDropsites() && cmpResourceDropsite.IsShared() && cmpOwnership && IsOwnedByMutualAllyOfPlayer(cmpOwnership.GetOwner(), target); }; UnitAI.prototype.CanTrade = function(target) { if (this.IsTurret()) return false; // Formation controllers should always respond to commands // (then the individual units can make up their own minds) if (this.IsFormationController()) return true; // Verify that we're able to respond to Trade commands var cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); return cmpTrader && cmpTrader.CanTrade(target); }; UnitAI.prototype.CanRepair = function(target) { if (this.IsTurret()) return false; // Formation controllers should always respond to commands // (then the individual units can make up their own minds) if (this.IsFormationController()) return true; // Verify that we're able to respond to Repair (Builder) commands var cmpBuilder = Engine.QueryInterface(this.entity, IID_Builder); if (!cmpBuilder) return false; // Verify that the target can be either built or repaired var cmpFoundation = QueryMiragedInterface(target, IID_Foundation); var cmpRepairable = Engine.QueryInterface(target, IID_Repairable); if (!cmpFoundation && !cmpRepairable) return false; // Verify that the target is owned by an ally of this entity's player var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); return cmpOwnership && IsOwnedByAllyOfPlayer(cmpOwnership.GetOwner(), target); }; UnitAI.prototype.CanPack = function() { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); return cmpPack && !cmpPack.IsPacking() && !cmpPack.IsPacked(); }; UnitAI.prototype.CanUnpack = function() { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); return cmpPack && !cmpPack.IsPacking() && cmpPack.IsPacked(); }; UnitAI.prototype.IsPacking = function() { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); return cmpPack && cmpPack.IsPacking(); }; //// Formation specific functions //// UnitAI.prototype.IsAttackingAsFormation = function() { var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); return cmpAttack && cmpAttack.CanAttackAsFormation() && this.GetCurrentState() == "FORMATIONCONTROLLER.COMBAT.ATTACKING"; }; //// Animal specific functions //// UnitAI.prototype.MoveRandomly = function(distance) { // To minimize drift all across the map, animals describe circles // approximated by polygons. // And to avoid getting stuck in obstacles or narrow spaces, each side // of the polygon is obtained by trying to go away from a point situated // half a meter backwards of the current position, after rotation. // We also add a fluctuation on the length of each side of the polygon (dist) // which, in addition to making the move more random, helps escaping narrow spaces // with bigger values of dist. let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (!cmpPosition || !cmpPosition.IsInWorld() || !cmpUnitMotion) return; let pos = cmpPosition.GetPosition(); let ang = cmpPosition.GetRotation().y; if (!this.roamAngle) { this.roamAngle = (randBool() ? 1 : -1) * Math.PI / 6; ang -= this.roamAngle / 2; this.startAngle = ang; } else if (Math.abs((ang - this.startAngle + Math.PI) % (2 * Math.PI) - Math.PI) < Math.abs(this.roamAngle / 2)) this.roamAngle *= randBool() ? 1 : -1; let halfDelta = randFloat(this.roamAngle / 4, this.roamAngle * 3 / 4); // First half rotation to decrease the impression of immediate rotation ang += halfDelta; cmpUnitMotion.FaceTowardsPoint(pos.x + 0.5 * Math.sin(ang), pos.z + 0.5 * Math.cos(ang)); // Then second half of the rotation ang += halfDelta; let dist = randFloat(0.5, 1.5) * distance; cmpUnitMotion.MoveToPointRange(pos.x - 0.5 * Math.sin(ang), pos.z - 0.5 * Math.cos(ang), dist, -1); }; UnitAI.prototype.SetFacePointAfterMove = function(val) { var cmpMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpMotion) cmpMotion.SetFacePointAfterMove(val); }; UnitAI.prototype.AttackEntitiesByPreference = function(ents) { if (!ents.length) return false; var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return false; var attackfilter = function(e) { var cmpOwnership = Engine.QueryInterface(e, IID_Ownership); if (cmpOwnership && cmpOwnership.GetOwner() > 0) return true; var cmpUnitAI = Engine.QueryInterface(e, IID_UnitAI); return cmpUnitAI && (!cmpUnitAI.IsAnimal() || cmpUnitAI.IsDangerousAnimal()); }; let entsByPreferences = {}; let preferences = []; let entsWithoutPref = []; for (let ent of ents) { if (!attackfilter(ent)) continue; let pref = cmpAttack.GetPreference(ent); if (pref === null || pref === undefined) entsWithoutPref.push(ent); else if (!entsByPreferences[pref]) { preferences.push(pref); entsByPreferences[pref] = [ent]; } else entsByPreferences[pref].push(ent); } if (preferences.length) { preferences.sort((a, b) => a - b); for (let pref of preferences) if (this.RespondToTargetedEntities(entsByPreferences[pref])) return true; } return this.RespondToTargetedEntities(entsWithoutPref); }; /** * Call obj.funcname(args) on UnitAI components of all formation members. */ UnitAI.prototype.CallMemberFunction = function(funcname, args) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (!cmpFormation) return; cmpFormation.GetMembers().forEach(ent => { var cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); cmpUnitAI[funcname].apply(cmpUnitAI, args); }); }; /** * Call obj.functname(args) on UnitAI components of all formation members, * and return true if all calls return true. */ UnitAI.prototype.TestAllMemberFunction = function(funcname, args) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (!cmpFormation) return false; return cmpFormation.GetMembers().every(ent => { var cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); return cmpUnitAI[funcname].apply(cmpUnitAI, args); }); }; UnitAI.prototype.UnitFsm = new FSM(UnitAI.prototype.UnitFsmSpec); Engine.RegisterComponentType(IID_UnitAI, "UnitAI", UnitAI); Index: ps/trunk/binaries/data/mods/public/simulation/components/interfaces/DelayedDamage.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/interfaces/DelayedDamage.js (nonexistent) +++ ps/trunk/binaries/data/mods/public/simulation/components/interfaces/DelayedDamage.js (revision 22754) @@ -0,0 +1 @@ +Engine.RegisterInterface("DelayedDamage"); Property changes on: ps/trunk/binaries/data/mods/public/simulation/components/interfaces/DelayedDamage.js ___________________________________________________________________ Added: svn:eol-style ## -0,0 +1 ## +native \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/simulation/components/interfaces/Resistance.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/interfaces/Resistance.js (nonexistent) +++ ps/trunk/binaries/data/mods/public/simulation/components/interfaces/Resistance.js (revision 22754) @@ -0,0 +1,6 @@ +Engine.RegisterInterface("Resistance"); + +/** + * Message of the form { "entity": entity, "invulnerability": true/false } + */ +Engine.RegisterMessageType("InvulnerabilityChanged"); Property changes on: ps/trunk/binaries/data/mods/public/simulation/components/interfaces/Resistance.js ___________________________________________________________________ Added: svn:eol-style ## -0,0 +1 ## +native \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Attack.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Attack.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Attack.js (revision 22754) @@ -1,305 +1,323 @@ Engine.LoadHelperScript("DamageBonus.js"); -Engine.LoadHelperScript("DamageTypes.js"); +Engine.LoadHelperScript("Attacking.js"); Engine.LoadHelperScript("Player.js"); Engine.LoadHelperScript("ValueModification.js"); Engine.LoadComponentScript("interfaces/Attack.js"); Engine.LoadComponentScript("interfaces/AuraManager.js"); Engine.LoadComponentScript("interfaces/Auras.js"); Engine.LoadComponentScript("interfaces/Capturable.js"); Engine.LoadComponentScript("interfaces/Formation.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/TechnologyManager.js"); Engine.LoadComponentScript("Attack.js"); let entityID = 903; function attackComponentTest(defenderClass, isEnemy, test_function) { ResetState(); { let playerEnt1 = 5; AddMock(SYSTEM_ENTITY, IID_PlayerManager, { "GetPlayerByID": () => playerEnt1 }); AddMock(playerEnt1, IID_Player, { "GetPlayerID": () => 1, "IsEnemy": () => isEnemy }); } let attacker = entityID; AddMock(attacker, IID_Position, { "IsInWorld": () => true, "GetHeightOffset": () => 5, "GetPosition2D": () => new Vector2D(1, 2) }); AddMock(attacker, IID_Ownership, { "GetOwner": () => 1 }); let cmpAttack = ConstructComponent(attacker, "Attack", { "Melee": { "Damage": { "Hack": 11, "Pierce": 5, "Crush": 0 }, "MinRange": 3, "MaxRange": 5, "PreferredClasses": { "_string": "FemaleCitizen" }, "RestrictedClasses": { "_string": "Elephant Archer" }, "Bonuses": { "BonusCav": { "Classes": "Cavalry", "Multiplier": 2 } } }, "Ranged": { "Damage": { "Hack": 0, "Pierce": 10, "Crush": 0 }, "MinRange": 10, "MaxRange": 80, "PrepareTime": 300, "RepeatTime": 500, "Projectile": { "Speed": 10, "Spread": 2, "Gravity": 1 }, "PreferredClasses": { "_string": "Archer" }, "RestrictedClasses": { "_string": "Elephant" }, "Splash": { "Shape": "Circular", "Range": 10, "FriendlyFire": "false", "Damage": { "Hack": 0.0, "Pierce": 15.0, "Crush": 35.0 }, "Bonuses": { "BonusCav": { "Classes": "Cavalry", "Multiplier": 3 } } } }, "Capture": { - "Value": 8, + "Capture": 8, "MaxRange": 10, }, "Slaughter": {} }); let defender = ++entityID; AddMock(defender, IID_Identity, { "GetClassesList": () => [defenderClass], "HasClass": className => className == defenderClass }); AddMock(defender, IID_Ownership, { "GetOwner": () => 1 }); AddMock(defender, IID_Position, { "IsInWorld": () => true, "GetHeightOffset": () => 0 }); AddMock(defender, IID_Health, { "GetHitpoints": () => 100 }); test_function(attacker, cmpAttack, defender); } // Validate template getter functions attackComponentTest(undefined, true, (attacker, cmpAttack, defender) => { TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(), ["Melee", "Ranged", "Capture"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes([]), ["Melee", "Ranged", "Capture"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Melee", "Ranged", "Capture"]), ["Melee", "Ranged", "Capture"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Melee", "Ranged"]), ["Melee", "Ranged"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Capture"]), ["Capture"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Melee", "!Melee"]), []); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["!Melee"]), ["Ranged", "Capture"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["!Melee", "!Ranged"]), ["Capture"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Capture", "!Ranged"]), ["Capture"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Capture", "Melee", "!Ranged"]), ["Melee", "Capture"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetPreferredClasses("Melee"), ["FemaleCitizen"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetRestrictedClasses("Melee"), ["Elephant", "Archer"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetFullAttackRange(), { "min": 0, "max": 80 }); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackStrengths("Capture"), { "value": 8 }); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Capture"), { "Capture": 8 }); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackStrengths("Ranged"), { - "Hack": 0, - "Pierce": 10, - "Crush": 0 + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Ranged"), { + "Damage": { + "Hack": 0, + "Pierce": 10, + "Crush": 0 + } }); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackStrengths("Ranged.Splash"), { - "Hack": 0.0, - "Pierce": 15.0, - "Crush": 35.0 + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Ranged", true), { + "Damage": { + "Hack": 0.0, + "Pierce": 15.0, + "Crush": 35.0 + }, + "Bonuses": { + "BonusCav": { + "Classes": "Cavalry", + "Multiplier": 3 + } + } }); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetTimers("Ranged"), { "prepare": 300, "repeat": 500 }); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetTimers("Capture"), { "prepare": 0, "repeat": 1000 }); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetSplashDamage("Ranged"), { - "damage": { - "Hack": 0, - "Pierce": 15, - "Crush": 35, + "attackData": { + "Damage": { + "Hack": 0, + "Pierce": 15, + "Crush": 35, + }, + "Bonuses": { + "BonusCav": { + "Classes": "Cavalry", + "Multiplier": 3 + } + } }, "friendlyFire": false, "shape": "Circular" }); }); for (let className of ["Infantry", "Cavalry"]) attackComponentTest(className, true, (attacker, cmpAttack, defender) => { - TS_ASSERT_EQUALS(cmpAttack.GetBonusTemplate("Melee").BonusCav.Multiplier, 2); + TS_ASSERT_EQUALS(cmpAttack.GetAttackEffectsData("Melee").Bonuses.BonusCav.Multiplier, 2); - TS_ASSERT(cmpAttack.GetBonusTemplate("Capture") === null); + TS_ASSERT_EQUALS(cmpAttack.GetAttackEffectsData("Capture").Bonuses || null, null); - let getAttackBonus = (s, t, e) => GetDamageBonus(s, e, t, cmpAttack.GetBonusTemplate(t)); + let getAttackBonus = (s, t, e, splash) => GetAttackBonus(s, e, t, cmpAttack.GetAttackEffectsData(t, splash).Bonuses || null); TS_ASSERT_UNEVAL_EQUALS(getAttackBonus(attacker, "Melee", defender), className == "Cavalry" ? 2 : 1); TS_ASSERT_UNEVAL_EQUALS(getAttackBonus(attacker, "Ranged", defender), 1); - TS_ASSERT_UNEVAL_EQUALS(getAttackBonus(attacker, "Ranged.Splash", defender), className == "Cavalry" ? 3 : 1); + TS_ASSERT_UNEVAL_EQUALS(getAttackBonus(attacker, "Ranged", defender, true), className == "Cavalry" ? 3 : 1); TS_ASSERT_UNEVAL_EQUALS(getAttackBonus(attacker, "Capture", defender), 1); TS_ASSERT_UNEVAL_EQUALS(getAttackBonus(attacker, "Slaughter", defender), 1); }); // CanAttack rejects elephant attack due to RestrictedClasses attackComponentTest("Elephant", true, (attacker, cmpAttack, defender) => { TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender), false); }); function testGetBestAttackAgainst(defenderClass, bestAttack, bestAllyAttack, isBuilding = false) { attackComponentTest(defenderClass, true, (attacker, cmpAttack, defender) => { if (isBuilding) AddMock(defender, IID_Capturable, { "CanCapture": playerID => { TS_ASSERT_EQUALS(playerID, 1); return true; } }); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender), true); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, []), true); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Ranged"]), true); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["!Melee"]), true); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Capture"]), isBuilding); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Melee", "Capture"]), defenderClass != "Archer"); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Ranged", "Capture"]), true); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["!Ranged", "!Melee"]), isBuilding || defenderClass == "Domestic"); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Melee", "!Melee"]), false); let allowCapturing = [true]; if (!isBuilding) allowCapturing.push(false); for (let ac of allowCapturing) TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, ac), bestAttack); }); attackComponentTest(defenderClass, false, (attacker, cmpAttack, defender) => { if (isBuilding) AddMock(defender, IID_Capturable, { "CanCapture": playerID => { TS_ASSERT_EQUALS(playerID, 1); return true; } }); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender), isBuilding || defenderClass == "Domestic"); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, []), isBuilding || defenderClass == "Domestic"); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Ranged"]), false); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["!Melee"]), isBuilding || defenderClass == "Domestic"); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Capture"]), isBuilding); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Melee", "Capture"]), isBuilding); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Ranged", "Capture"]), isBuilding); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["!Ranged", "!Melee"]), isBuilding || defenderClass == "Domestic"); TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Melee", "!Melee"]), false); let allowCapturing = [true]; if (!isBuilding) allowCapturing.push(false); for (let ac of allowCapturing) TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, ac), bestAllyAttack); }); } testGetBestAttackAgainst("FemaleCitizen", "Melee", undefined); testGetBestAttackAgainst("Archer", "Ranged", undefined); testGetBestAttackAgainst("Domestic", "Slaughter", "Slaughter"); testGetBestAttackAgainst("Structure", "Capture", "Capture", true); testGetBestAttackAgainst("Structure", "Ranged", undefined, false); function testPredictTimeToTarget(selfPosition, horizSpeed, targetPosition, targetVelocity) { ResetState(); let cmpAttack = ConstructComponent(1, "Attack", {}); let timeToTarget = cmpAttack.PredictTimeToTarget(selfPosition, horizSpeed, targetPosition, targetVelocity); if (timeToTarget === false) return; // Position of the target after that time. let targetPos = Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition); // Time that the projectile need to reach it. let time = targetPos.horizDistanceTo(selfPosition) / horizSpeed; TS_ASSERT_EQUALS(timeToTarget.toFixed(1), time.toFixed(1)); } testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(0, 0, 0), new Vector3D(0, 0, 0)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(0, 0, 0)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(1, 0, 0)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(4, 0, 0)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(16, 0, 0)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(-1, 0, 0)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(-4, 0, 0)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(-16, 0, 0)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(0, 0, 1)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(0, 0, 4)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(0, 0, 16)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(1, 0, 1)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(2, 0, 2)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(8, 0, 8)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(-1, 0, 1)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(-2, 0, 2)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(-8, 0, 8)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(4, 0, 2)); testPredictTimeToTarget(new Vector3D(0, 0, 0), 4, new Vector3D(20, 0, 0), new Vector3D(-4, 0, 2)); Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Damage.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Damage.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Damage.js (revision 22754) @@ -1,551 +1,548 @@ Engine.LoadHelperScript("DamageBonus.js"); -Engine.LoadHelperScript("DamageTypes.js"); +Engine.LoadHelperScript("Attacking.js"); Engine.LoadHelperScript("Player.js"); Engine.LoadHelperScript("Sound.js"); Engine.LoadHelperScript("ValueModification.js"); Engine.LoadComponentScript("interfaces/Attack.js"); Engine.LoadComponentScript("interfaces/AttackDetection.js"); Engine.LoadComponentScript("interfaces/AuraManager.js"); -Engine.LoadComponentScript("interfaces/Damage.js"); -Engine.LoadComponentScript("interfaces/DamageReceiver.js"); +Engine.LoadComponentScript("interfaces/DelayedDamage.js"); +Engine.LoadComponentScript("interfaces/Resistance.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/Loot.js"); Engine.LoadComponentScript("interfaces/Player.js"); Engine.LoadComponentScript("interfaces/Promotion.js"); Engine.LoadComponentScript("interfaces/StatusEffectsReceiver.js"); Engine.LoadComponentScript("interfaces/TechnologyManager.js"); Engine.LoadComponentScript("interfaces/Timer.js"); Engine.LoadComponentScript("Attack.js"); -Engine.LoadComponentScript("Damage.js"); +Engine.LoadComponentScript("DelayedDamage.js"); Engine.LoadComponentScript("Timer.js"); function Test_Generic() { ResetState(); - let cmpDamage = ConstructComponent(SYSTEM_ENTITY, "Damage"); let cmpTimer = ConstructComponent(SYSTEM_ENTITY, "Timer"); - cmpTimer.OnUpdate({ turnLength: 1 }); + cmpTimer.OnUpdate({ "turnLength": 1 }); let attacker = 11; let atkPlayerEntity = 1; let attackerOwner = 6; let cmpAttack = ConstructComponent(attacker, "Attack", { "Ranged": { + "Damage": { + "Crush": 5, + }, "MaxRange": 50, "MinRange": 0, "Delay": 0, "Projectile": { "Speed": 75.0, "Spread": 0.5, "Gravity": 9.81, "LaunchPoint": { "@y": 3 } } } }); let damage = 5; let target = 21; let targetOwner = 7; let targetPos = new Vector3D(3, 0, 3); let type = "Melee"; let damageTaken = false; - cmpAttack.GetAttackStrengths = attackType => ({ "hack": 0, "pierce": 0, "crush": damage }); + cmpAttack.GetAttackStrengths = attackType => ({ "Hack": 0, "Pierce": 0, "Crush": damage }); let data = { - "attacker": attacker, - "target": target, "type": "Melee", - "strengths": { "hack": 0, "pierce": 0, "crush": damage }, - "multiplier": 1.0, + "attackData": { + "Damage": { "Hack": 0, "Pierce": 0, "Crush": damage }, + }, + "target": target, + "attacker": attacker, "attackerOwner": attackerOwner, "position": targetPos, - "isSplash": false, "projectileId": 9, - "direction": new Vector3D(1,0,0) + "direction": new Vector3D(1, 0, 0) }; AddMock(atkPlayerEntity, IID_Player, { "GetEnemies": () => [targetOwner] }); AddMock(SYSTEM_ENTITY, IID_PlayerManager, { "GetPlayerByID": id => atkPlayerEntity, "GetAllPlayers": () => [0, 1, 2, 3, 4] }); AddMock(SYSTEM_ENTITY, IID_ProjectileManager, { "RemoveProjectile": () => {}, "LaunchProjectileAtPoint": (ent, pos, speed, gravity) => {}, }); AddMock(target, IID_Position, { "GetPosition": () => targetPos, "GetPreviousPosition": () => targetPos, "GetPosition2D": () => Vector2D.From(targetPos), "IsInWorld": () => true, }); - AddMock(target, IID_Health, {}); + AddMock(target, IID_Health, { + "TakeDamage": (effectData, __, ___, bonusMultiplier) => { + damageTaken = true; + return { "killed": false, "HPchange": -bonusMultiplier * effectData.Crush }; + }, + }); - AddMock(target, IID_DamageReceiver, { - "TakeDamage": (strengths, multiplier) => { damageTaken = true; return { "killed": false, "change": -multiplier * strengths.crush }; }, + AddMock(SYSTEM_ENTITY, IID_DelayedDamage, { + "MissileHit": () => { + damageTaken = true; + }, }); Engine.PostMessage = function(ent, iid, message) { - TS_ASSERT_UNEVAL_EQUALS({ "attacker": attacker, "target": target, "type": type, "damage": damage, "attackerOwner": attackerOwner }, message); + TS_ASSERT_UNEVAL_EQUALS({ "type": type, "target": target, "attacker": attacker, "attackerOwner": attackerOwner, "damage": damage, "capture": 0, "statusEffects": [] }, message); }; AddMock(target, IID_Footprint, { "GetShape": () => ({ "type": "circle", "radius": 20 }), }); AddMock(attacker, IID_Ownership, { "GetOwner": () => attackerOwner, }); AddMock(attacker, IID_Position, { "GetPosition": () => new Vector3D(2, 0, 3), "GetRotation": () => new Vector3D(1, 2, 3), "IsInWorld": () => true, }); function TestDamage() { - cmpTimer.OnUpdate({ turnLength: 1 }); + cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT(damageTaken); damageTaken = false; } - cmpDamage.CauseDamage(data); + Attacking.HandleAttackEffects(data.type, data.attackData, data.target, data.attacker, data.attackerOwner); TestDamage(); - type = data.type = "Ranged"; - cmpDamage.CauseDamage(data); + data.type = "Ranged"; + type = data.type; + Attacking.HandleAttackEffects(data.type, data.attackData, data.target, data.attacker, data.attackerOwner); TestDamage(); // Check for damage still being dealt if the attacker dies cmpAttack.PerformAttack("Ranged", target); Engine.DestroyEntity(attacker); TestDamage(); atkPlayerEntity = 1; AddMock(atkPlayerEntity, IID_Player, { "GetEnemies": () => [2, 3] }); - TS_ASSERT_UNEVAL_EQUALS(cmpDamage.GetPlayersToDamage(atkPlayerEntity, true), [0, 1, 2, 3, 4]); - TS_ASSERT_UNEVAL_EQUALS(cmpDamage.GetPlayersToDamage(atkPlayerEntity, false), [2, 3]); + TS_ASSERT_UNEVAL_EQUALS(Attacking.GetPlayersToDamage(atkPlayerEntity, true), [0, 1, 2, 3, 4]); + TS_ASSERT_UNEVAL_EQUALS(Attacking.GetPlayersToDamage(atkPlayerEntity, false), [2, 3]); } Test_Generic(); function TestLinearSplashDamage() { ResetState(); Engine.PostMessage = (ent, iid, message) => {}; const attacker = 50; const attackerOwner = 1; const origin = new Vector2D(0, 0); let data = { + "type": "Ranged", + "attackData": { "Damage": { "Hack": 100, "Pierce": 0, "Crush": 0 } }, "attacker": attacker, + "attackerOwner": attackerOwner, "origin": origin, "radius": 10, "shape": "Linear", - "strengths": { "hack" : 100, "pierce" : 0, "crush": 0 }, "direction": new Vector3D(1, 747, 0), "playersToDamage": [2], - "type": "Ranged", - "attackerOwner": attackerOwner }; - let fallOff = function(x,y) + let fallOff = function(x, y) { return (1 - x * x / (data.radius * data.radius)) * (1 - 25 * y * y / (data.radius * data.radius)); }; let hitEnts = new Set(); - let cmpDamage = ConstructComponent(SYSTEM_ENTITY, "Damage"); - AddMock(SYSTEM_ENTITY, IID_RangeManager, { "ExecuteQueryAroundPos": () => [60, 61, 62], }); AddMock(60, IID_Position, { "GetPosition2D": () => new Vector2D(2.2, -0.4), }); AddMock(61, IID_Position, { "GetPosition2D": () => new Vector2D(0, 0), }); AddMock(62, IID_Position, { "GetPosition2D": () => new Vector2D(5, 2), }); - AddMock(60, IID_DamageReceiver, { - "TakeDamage": (strengths, multiplier) => { + AddMock(60, IID_Health, { + "TakeDamage": (effectData, __, ___, mult) => { hitEnts.add(60); - TS_ASSERT_EQUALS(multiplier * (strengths.hack + strengths.pierce + strengths.crush), 100 * fallOff(2.2, -0.4)); - return { "killed": false, "change": -multiplier * (strengths.hack + strengths.pierce + strengths.crush) }; + TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100 * fallOff(2.2, -0.4)); + return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); - AddMock(61, IID_DamageReceiver, { - "TakeDamage": (strengths, multiplier) => { + AddMock(61, IID_Health, { + "TakeDamage": (effectData, __, ___, mult) => { hitEnts.add(61); - TS_ASSERT_EQUALS(multiplier * (strengths.hack + strengths.pierce + strengths.crush), 100 * fallOff(0, 0)); - return { "killed": false, "change": -multiplier * (strengths.hack + strengths.pierce + strengths.crush) }; + TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100 * fallOff(0, 0)); + return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); - AddMock(62, IID_DamageReceiver, { - "TakeDamage": (strengths, multiplier) => { + AddMock(62, IID_Health, { + "TakeDamage": (effectData, __, ___, mult) => { hitEnts.add(62); - TS_ASSERT_EQUALS(multiplier * (strengths.hack + strengths.pierce + strengths.crush), 0); - return { "killed": false, "change": -multiplier * (strengths.hack + strengths.pierce + strengths.crush) }; + TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 0); + return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); - cmpDamage.CauseDamageOverArea(data); + Attacking.CauseDamageOverArea(data); TS_ASSERT(hitEnts.has(60)); TS_ASSERT(hitEnts.has(61)); TS_ASSERT(hitEnts.has(62)); hitEnts.clear(); data.direction = new Vector3D(0.6, 747, 0.8); - AddMock(60, IID_DamageReceiver, { - "TakeDamage": (strengths, multiplier) => { + AddMock(60, IID_Health, { + "TakeDamage": (effectData, __, ___, mult) => { hitEnts.add(60); - TS_ASSERT_EQUALS(multiplier * (strengths.hack + strengths.pierce + strengths.crush), 100 * fallOff(1, 2)); - return { "killed": false, "change": -multiplier * (strengths.hack + strengths.pierce + strengths.crush) }; + TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100 * fallOff(1, 2)); + return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); - cmpDamage.CauseDamageOverArea(data); + Attacking.CauseDamageOverArea(data); TS_ASSERT(hitEnts.has(60)); TS_ASSERT(hitEnts.has(61)); TS_ASSERT(hitEnts.has(62)); hitEnts.clear(); } TestLinearSplashDamage(); function TestCircularSplashDamage() { ResetState(); Engine.PostMessage = (ent, iid, message) => {}; const radius = 10; let fallOff = function(r) { return 1 - r * r / (radius * radius); }; - let cmpDamage = ConstructComponent(SYSTEM_ENTITY, "Damage"); - AddMock(SYSTEM_ENTITY, IID_RangeManager, { "ExecuteQueryAroundPos": () => [60, 61, 62, 64], }); AddMock(60, IID_Position, { "GetPosition2D": () => new Vector2D(3, 4), }); AddMock(61, IID_Position, { "GetPosition2D": () => new Vector2D(0, 0), }); AddMock(62, IID_Position, { "GetPosition2D": () => new Vector2D(3.6, 3.2), }); AddMock(63, IID_Position, { "GetPosition2D": () => new Vector2D(10, -10), }); // Target on the frontier of the shape AddMock(64, IID_Position, { "GetPosition2D": () => new Vector2D(9, -4), }); - AddMock(60, IID_DamageReceiver, { - "TakeDamage": (strengths, multiplier) => { - TS_ASSERT_EQUALS(multiplier * (strengths.hack + strengths.pierce + strengths.crush), 100 * fallOff(0)); - return { "killed": false, "change": -multiplier * (strengths.hack + strengths.pierce + strengths.crush) }; + AddMock(60, IID_Resistance, { + "TakeDamage": (effectData, __, ___, mult) => { + TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100 * fallOff(0)); + return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); - AddMock(61, IID_DamageReceiver, { - "TakeDamage": (strengths, multiplier) => { - TS_ASSERT_EQUALS(multiplier * (strengths.hack + strengths.pierce + strengths.crush), 100 * fallOff(5)); - return { "killed": false, "change": -multiplier * (strengths.hack + strengths.pierce + strengths.crush) }; + AddMock(61, IID_Resistance, { + "TakeDamage": (effectData, __, ___, mult) => { + TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100 * fallOff(5)); + return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); - AddMock(62, IID_DamageReceiver, { - "TakeDamage": (strengths, multiplier) => { - TS_ASSERT_EQUALS(multiplier * (strengths.hack + strengths.pierce + strengths.crush), 100 * fallOff(1)); - return { "killed": false, "change": -multiplier * (strengths.hack + strengths.pierce + strengths.crush) }; + AddMock(62, IID_Resistance, { + "TakeDamage": (effectData, __, ___, mult) => { + TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100 * fallOff(1)); + return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); - AddMock(63, IID_DamageReceiver, { - "TakeDamage": (strengths, multiplier) => { + AddMock(63, IID_Resistance, { + "TakeDamage": (effectData, __, ___, mult) => { TS_ASSERT(false); } }); - AddMock(64, IID_DamageReceiver, { - "TakeDamage": (strengths, multiplier) => { - TS_ASSERT_EQUALS(multiplier * (strengths.hack + strengths.pierce + strengths.crush), 0); - return { "killed": false, "change": -multiplier * (strengths.hack + strengths.pierce + strengths.crush) }; + AddMock(64, IID_Resistance, { + "TakeDamage": (effectData, __, ___, mult) => { + TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 0); + return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); - cmpDamage.CauseDamageOverArea({ + Attacking.CauseDamageOverArea({ + "type": "Ranged", + "attackData": { "Damage": { "Hack": 100, "Pierce": 0, "Crush": 0 } }, "attacker": 50, + "attackerOwner": 1, "origin": new Vector2D(3, 4), "radius": radius, "shape": "Circular", - "strengths": { "hack" : 100, "pierce" : 0, "crush": 0 }, "playersToDamage": [2], - "type": "Ranged", - "attackerOwner": 1 }); } TestCircularSplashDamage(); function Test_MissileHit() { ResetState(); Engine.PostMessage = (ent, iid, message) => {}; - let cmpDamage = ConstructComponent(SYSTEM_ENTITY, "Damage"); + let cmpDelayedDamage = ConstructComponent(SYSTEM_ENTITY, "DelayedDamage"); let target = 60; let targetOwner = 1; let targetPos = new Vector3D(3, 10, 0); let hitEnts = new Set(); AddMock(SYSTEM_ENTITY, IID_Timer, { "GetLatestTurnLength": () => 500 }); const radius = 10; let data = { "type": "Ranged", - "attacker": 70, + "attackData": { "Damage": { "Hack": 0, "Pierce": 100, "Crush": 0 } }, "target": 60, - "strengths": { "hack": 0, "pierce": 100, "crush": 0 }, + "attacker": 70, + "attackerOwner": 1, "position": targetPos, "direction": new Vector3D(1, 0, 0), "projectileId": 9, - "bonus": undefined, - "isSplash": false, - "attackerOwner": 1 }; AddMock(SYSTEM_ENTITY, IID_PlayerManager, { "GetPlayerByID": id => id == 1 ? 10 : 11, "GetAllPlayers": () => [0, 1] }); AddMock(SYSTEM_ENTITY, IID_ProjectileManager, { "RemoveProjectile": () => {}, "LaunchProjectileAtPoint": (ent, pos, speed, gravity) => {}, }); AddMock(60, IID_Position, { "GetPosition": () => targetPos, "GetPreviousPosition": () => targetPos, "GetPosition2D": () => Vector2D.From(targetPos), "IsInWorld": () => true, }); - AddMock(60, IID_Health, {}); - - AddMock(60, IID_DamageReceiver, { - "TakeDamage": (strengths, multiplier) => { + AddMock(60, IID_Health, { + "TakeDamage": (effectData, __, ___, mult) => { hitEnts.add(60); - TS_ASSERT_EQUALS(multiplier * (strengths.hack + strengths.pierce + strengths.crush), 100); - return { "killed": false, "change": -multiplier * (strengths.hack + strengths.pierce + strengths.crush) }; + TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100); + return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); AddMock(60, IID_Footprint, { "GetShape": () => ({ "type": "circle", "radius": 20 }), }); AddMock(70, IID_Ownership, { "GetOwner": () => 1, }); AddMock(70, IID_Position, { "GetPosition": () => new Vector3D(0, 0, 0), "GetRotation": () => new Vector3D(0, 0, 0), "IsInWorld": () => true, }); AddMock(10, IID_Player, { "GetEnemies": () => [2] }); - cmpDamage.MissileHit(data, 0); + cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(60)); hitEnts.clear(); // The main target is not hit but another one is hit. AddMock(60, IID_Position, { "GetPosition": () => new Vector3D(900, 10, 0), "GetPreviousPosition": () => new Vector3D(900, 10, 0), "GetPosition2D": () => new Vector2D(900, 0), "IsInWorld": () => true, }); - AddMock(60, IID_DamageReceiver, { - "TakeDamage": (strengths, multiplier) => { + AddMock(60, IID_Health, { + "TakeDamage": (effectData, __, ___, mult) => { TS_ASSERT_EQUALS(false); - return { "killed": false, "change": -multiplier * (strengths.hack + strengths.pierce + strengths.crush) }; + return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); AddMock(SYSTEM_ENTITY, IID_RangeManager, { "ExecuteQueryAroundPos": () => [61] }); AddMock(61, IID_Position, { "GetPosition": () => targetPos, "GetPreviousPosition": () => targetPos, "GetPosition2D": () => Vector2D.from3D(targetPos), "IsInWorld": () => true, }); - AddMock(61, IID_Health, {}); - - AddMock(61, IID_DamageReceiver, { - "TakeDamage": (strengths, multiplier) => { - TS_ASSERT_EQUALS(multiplier * (strengths.hack + strengths.pierce + strengths.crush), 100); + AddMock(61, IID_Health, { + "TakeDamage": (effectData, __, ___, mult) => { hitEnts.add(61); - return { "killed": false, "change": -multiplier * (strengths.hack + strengths.pierce + strengths.crush) }; + TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 100); + return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); AddMock(61, IID_Footprint, { "GetShape": () => ({ "type": "circle", "radius": 20 }), }); - cmpDamage.MissileHit(data, 0); + cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); hitEnts.clear(); // Add a splash damage. - - data.friendlyFire = false; - data.radius = 10; - data.shape = "Circular"; - data.isSplash = true; - data.splashStrengths = { "hack": 0, "pierce": 0, "crush": 200 }; + data.splash = {}; + data.splash.friendlyFire = false; + data.splash.radius = 10; + data.splash.shape = "Circular"; + data.splash.attackData = { "Damage": { "Hack": 0, "Pierce": 0, "Crush": 200 } }; AddMock(SYSTEM_ENTITY, IID_RangeManager, { "ExecuteQueryAroundPos": () => [61, 62] }); let dealtDamage = 0; - AddMock(61, IID_DamageReceiver, { - "TakeDamage": (strengths, multiplier) => { - dealtDamage += multiplier * (strengths.hack + strengths.pierce + strengths.crush); + AddMock(61, IID_Health, { + "TakeDamage": (effectData, __, ___, mult) => { hitEnts.add(61); - return { "killed": false, "change": -multiplier * (strengths.hack + strengths.pierce + strengths.crush) }; + dealtDamage += mult * (effectData.Hack + effectData.Pierce + effectData.Crush); + return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); AddMock(62, IID_Position, { "GetPosition": () => new Vector3D(8, 10, 0), "GetPreviousPosition": () => new Vector3D(8, 10, 0), "GetPosition2D": () => new Vector2D(8, 0), "IsInWorld": () => true, }); - AddMock(62, IID_Health, {}); - - AddMock(62, IID_DamageReceiver, { - "TakeDamage": (strengths, multiplier) => { - TS_ASSERT_EQUALS(multiplier * (strengths.hack + strengths.pierce + strengths.crush), 200 * 0.75); + AddMock(62, IID_Health, { + "TakeDamage": (effectData, __, ___, mult) => { hitEnts.add(62); - return { "killed": false, "change": -multiplier * (strengths.hack + strengths.pierce + strengths.crush) }; + TS_ASSERT_EQUALS(mult * (effectData.Hack + effectData.Pierce + effectData.Crush), 200 * 0.75); + return { "killed": false, "change": -mult * (effectData.Hack + effectData.Pierce + effectData.Crush) }; } }); AddMock(62, IID_Footprint, { "GetShape": () => ({ "type": "circle", "radius": 20 }), }); - cmpDamage.MissileHit(data, 0); + cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); TS_ASSERT_EQUALS(dealtDamage, 100 + 200); dealtDamage = 0; TS_ASSERT(hitEnts.has(62)); hitEnts.clear(); // Add some hard counters bonus. Engine.DestroyEntity(62); AddMock(SYSTEM_ENTITY, IID_RangeManager, { "ExecuteQueryAroundPos": () => [61] }); let bonus= { "BonusCav": { "Classes": "Cavalry", "Multiplier": 400 } }; let splashBonus = { "BonusCav": { "Classes": "Cavalry", "Multiplier": 10000 } }; AddMock(61, IID_Identity, { "HasClass": cl => cl == "Cavalry" }); - data.bonus = bonus; - cmpDamage.MissileHit(data, 0); + data.attackData.Bonuses = bonus; + cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); TS_ASSERT_EQUALS(dealtDamage, 400 * 100 + 200); dealtDamage = 0; hitEnts.clear(); - data.splashBonus = splashBonus; - cmpDamage.MissileHit(data, 0); + data.splash.attackData.Bonuses = splashBonus; + cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); TS_ASSERT_EQUALS(dealtDamage, 400 * 100 + 10000 * 200); dealtDamage = 0; hitEnts.clear(); - data.bonus = undefined; - cmpDamage.MissileHit(data, 0); + data.attackData.Bonuses = undefined; + cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); TS_ASSERT_EQUALS(dealtDamage, 100 + 10000 * 200); dealtDamage = 0; hitEnts.clear(); - data.bonus = null; - cmpDamage.MissileHit(data, 0); + data.attackData.Bonuses = null; + cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); TS_ASSERT_EQUALS(dealtDamage, 100 + 10000 * 200); dealtDamage = 0; hitEnts.clear(); - data.bonus = {}; - cmpDamage.MissileHit(data, 0); + data.attackData.Bonuses = {}; + cmpDelayedDamage.MissileHit(data, 0); TS_ASSERT(hitEnts.has(61)); TS_ASSERT_EQUALS(dealtDamage, 100 + 10000 * 200); dealtDamage = 0; hitEnts.clear(); } Test_MissileHit(); Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_DeathDamage.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_DeathDamage.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_DeathDamage.js (revision 22754) @@ -1,77 +1,75 @@ Engine.LoadHelperScript("DamageBonus.js"); -Engine.LoadHelperScript("DamageTypes.js"); +Engine.LoadHelperScript("Attacking.js"); Engine.LoadHelperScript("ValueModification.js"); Engine.LoadComponentScript("interfaces/AuraManager.js"); -Engine.LoadComponentScript("interfaces/Damage.js"); Engine.LoadComponentScript("interfaces/DeathDamage.js"); Engine.LoadComponentScript("interfaces/TechnologyManager.js"); Engine.LoadComponentScript("DeathDamage.js"); let deadEnt = 60; let player = 1; ApplyValueModificationsToEntity = function(value, stat, ent) { if (value == "DeathDamage/Damage/Pierce" && ent == deadEnt) return stat + 200; return stat; }; let template = { "Shape": "Circular", "Range": 10.7, "FriendlyFire": "false", "Damage": { "Hack": 0.0, "Pierce": 15.0, "Crush": 35.0 } }; -let modifiedDamage = { - "Hack": 0.0, - "Pierce": 215.0, - "Crush": 35.0 +let effects = { + "Damage": { + "Hack": 0.0, + "Pierce": 215.0, + "Crush": 35.0 + } }; let cmpDeathDamage = ConstructComponent(deadEnt, "DeathDamage", template); let playersToDamage = [2, 3, 7]; let pos = new Vector2D(3, 4.2); let result = { + "type": "Death", + "attackData": effects, "attacker": deadEnt, + "attackerOwner": player, "origin": pos, "radius": template.Range, "shape": template.Shape, - "strengths": modifiedDamage, - "splashBonus": null, - "playersToDamage": playersToDamage, - "type": "Death", - "attackerOwner": player + "playersToDamage": playersToDamage }; -AddMock(SYSTEM_ENTITY, IID_Damage, { - "CauseDamageOverArea": data => TS_ASSERT_UNEVAL_EQUALS(data, result), - "GetPlayersToDamage": (owner, friendlyFire) => playersToDamage -}); +Attacking.CauseDamageOverArea = data => TS_ASSERT_UNEVAL_EQUALS(data, result); +Attacking.GetPlayersToDamage = () => playersToDamage; AddMock(deadEnt, IID_Position, { "GetPosition2D": () => pos, "IsInWorld": () => true }); AddMock(deadEnt, IID_Ownership, { "GetOwner": () => player }); -TS_ASSERT_UNEVAL_EQUALS(cmpDeathDamage.GetDeathDamageStrengths(), modifiedDamage); +TS_ASSERT_UNEVAL_EQUALS(cmpDeathDamage.GetDeathDamageEffects(), effects); cmpDeathDamage.CauseDeathDamage(); // Test splash damage bonus -let splashBonus = { "BonusCav": { "Classes": "Cavalry", "Multiplier": 3 } }; -template.Bonuses = splashBonus; +effects.Bonuses = { "BonusCav": { "Classes": "Cavalry", "Multiplier": 3 } }; +template.Bonuses = effects.Bonuses; cmpDeathDamage = ConstructComponent(deadEnt, "DeathDamage", template); -result.splashBonus = splashBonus; -TS_ASSERT_UNEVAL_EQUALS(cmpDeathDamage.GetDeathDamageStrengths(), modifiedDamage); +result.attackData.Bonuses = effects.Bonuses; +TS_ASSERT_UNEVAL_EQUALS(cmpDeathDamage.GetDeathDamageEffects(), effects); cmpDeathDamage.CauseDeathDamage(); Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js (revision 22754) @@ -1,590 +1,590 @@ Engine.LoadHelperScript("Player.js"); Engine.LoadComponentScript("interfaces/Attack.js"); Engine.LoadComponentScript("interfaces/AlertRaiser.js"); Engine.LoadComponentScript("interfaces/Auras.js"); Engine.LoadComponentScript("interfaces/Barter.js"); Engine.LoadComponentScript("interfaces/Builder.js"); Engine.LoadComponentScript("interfaces/Capturable.js"); Engine.LoadComponentScript("interfaces/CeasefireManager.js"); -Engine.LoadComponentScript("interfaces/DamageReceiver.js"); +Engine.LoadComponentScript("interfaces/Resistance.js"); Engine.LoadComponentScript("interfaces/DeathDamage.js"); Engine.LoadComponentScript("interfaces/EndGameManager.js"); Engine.LoadComponentScript("interfaces/EntityLimits.js"); Engine.LoadComponentScript("interfaces/Foundation.js"); Engine.LoadComponentScript("interfaces/Garrisonable.js"); Engine.LoadComponentScript("interfaces/GarrisonHolder.js"); Engine.LoadComponentScript("interfaces/Gate.js"); Engine.LoadComponentScript("interfaces/Guard.js"); Engine.LoadComponentScript("interfaces/Heal.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/Loot.js"); Engine.LoadComponentScript("interfaces/Market.js"); Engine.LoadComponentScript("interfaces/Pack.js"); Engine.LoadComponentScript("interfaces/ProductionQueue.js"); Engine.LoadComponentScript("interfaces/Promotion.js"); Engine.LoadComponentScript("interfaces/Repairable.js"); Engine.LoadComponentScript("interfaces/ResourceDropsite.js"); Engine.LoadComponentScript("interfaces/ResourceGatherer.js"); Engine.LoadComponentScript("interfaces/ResourceTrickle.js"); Engine.LoadComponentScript("interfaces/ResourceSupply.js"); Engine.LoadComponentScript("interfaces/TechnologyManager.js"); Engine.LoadComponentScript("interfaces/Trader.js"); Engine.LoadComponentScript("interfaces/Timer.js"); Engine.LoadComponentScript("interfaces/StatisticsTracker.js"); Engine.LoadComponentScript("interfaces/UnitAI.js"); Engine.LoadComponentScript("interfaces/Upgrade.js"); Engine.LoadComponentScript("interfaces/BuildingAI.js"); Engine.LoadComponentScript("GuiInterface.js"); Resources = { "GetCodes": () => ["food", "metal", "stone", "wood"], "GetNames": () => ({ "food": "Food", "metal": "Metal", "stone": "Stone", "wood": "Wood" }), "GetResource": resource => ({ "aiAnalysisInfluenceGroup": resource == "food" ? "ignore" : resource == "wood" ? "abundant" : "sparse" }) }; var cmp = ConstructComponent(SYSTEM_ENTITY, "GuiInterface"); AddMock(SYSTEM_ENTITY, IID_Barter, { GetPrices: function() { return { "buy": { "food": 150 }, "sell": { "food": 25 } }; }, PlayerHasMarket: function () { return false; } }); AddMock(SYSTEM_ENTITY, IID_EndGameManager, { GetVictoryConditions: () => ["conquest", "wonder"], GetAlliedVictory: function() { return false; } }); AddMock(SYSTEM_ENTITY, IID_PlayerManager, { GetNumPlayers: function() { return 2; }, GetPlayerByID: function(id) { TS_ASSERT(id === 0 || id === 1); return 100+id; } }); AddMock(SYSTEM_ENTITY, IID_RangeManager, { GetLosVisibility: function(ent, player) { return "visible"; }, GetLosCircular: function() { return false; } }); AddMock(SYSTEM_ENTITY, IID_TemplateManager, { GetCurrentTemplateName: function(ent) { return "example"; }, GetTemplate: function(name) { return ""; } }); AddMock(SYSTEM_ENTITY, IID_Timer, { GetTime: function() { return 0; }, SetTimeout: function(ent, iid, funcname, time, data) { return 0; } }); AddMock(100, IID_Player, { GetName: function() { return "Player 1"; }, GetCiv: function() { return "gaia"; }, GetColor: function() { return { r: 1, g: 1, b: 1, a: 1}; }, CanControlAllUnits: function() { return false; }, GetPopulationCount: function() { return 10; }, GetPopulationLimit: function() { return 20; }, GetMaxPopulation: function() { return 200; }, GetResourceCounts: function() { return { food: 100 }; }, GetPanelEntities: function() { return []; }, IsTrainingBlocked: function() { return false; }, GetState: function() { return "active"; }, GetTeam: function() { return -1; }, GetLockTeams: function() { return false; }, GetCheatsEnabled: function() { return false; }, GetDiplomacy: function() { return [-1, 1]; }, IsAlly: function() { return false; }, IsMutualAlly: function() { return false; }, IsNeutral: function() { return false; }, IsEnemy: function() { return true; }, GetDisabledTemplates: function() { return {}; }, GetDisabledTechnologies: function() { return {}; }, GetSpyCostMultiplier: function() { return 1; }, HasSharedDropsites: function() { return false; }, HasSharedLos: function() { return false; } }); AddMock(100, IID_EntityLimits, { GetLimits: function() { return {"Foo": 10}; }, GetCounts: function() { return {"Foo": 5}; }, GetLimitChangers: function() {return {"Foo": {}}; } }); AddMock(100, IID_TechnologyManager, { "IsTechnologyResearched": tech => tech == "phase_village", "GetQueuedResearch": () => new Map(), "GetStartedTechs": () => new Set(), "GetResearchedTechs": () => new Set(), "GetClassCounts": () => ({}), "GetTypeCountsByClass": () => ({}) }); AddMock(100, IID_StatisticsTracker, { GetBasicStatistics: function() { return { "resourcesGathered": { "food": 100, "wood": 0, "metal": 0, "stone": 0, "vegetarianFood": 0 }, "percentMapExplored": 10 }; }, GetSequences: function() { return { "unitsTrained": [0, 10], "unitsLost": [0, 42], "buildingsConstructed": [1, 3], "buildingsCaptured": [3, 7], "buildingsLost": [3, 10], "civCentresBuilt": [4, 10], "resourcesGathered": { "food": [5, 100], "wood": [0, 0], "metal": [0, 0], "stone": [0, 0], "vegetarianFood": [0, 0] }, "treasuresCollected": [1, 20], "lootCollected": [0, 2], "percentMapExplored": [0, 10], "teamPercentMapExplored": [0, 10], "percentMapControlled": [0, 10], "teamPercentMapControlled": [0, 10], "peakPercentOfMapControlled": [0, 10], "teamPeakPercentOfMapControlled": [0, 10] }; }, IncreaseTrainedUnitsCounter: function() { return 1; }, IncreaseConstructedBuildingsCounter: function() { return 1; }, IncreaseBuiltCivCentresCounter: function() { return 1; } }); AddMock(101, IID_Player, { GetName: function() { return "Player 2"; }, GetCiv: function() { return "mace"; }, GetColor: function() { return { r: 1, g: 0, b: 0, a: 1}; }, CanControlAllUnits: function() { return true; }, GetPopulationCount: function() { return 40; }, GetPopulationLimit: function() { return 30; }, GetMaxPopulation: function() { return 300; }, GetResourceCounts: function() { return { food: 200 }; }, GetPanelEntities: function() { return []; }, IsTrainingBlocked: function() { return false; }, GetState: function() { return "active"; }, GetTeam: function() { return -1; }, GetLockTeams: function() {return false; }, GetCheatsEnabled: function() { return false; }, GetDiplomacy: function() { return [-1, 1]; }, IsAlly: function() { return true; }, IsMutualAlly: function() {return false; }, IsNeutral: function() { return false; }, IsEnemy: function() { return false; }, GetDisabledTemplates: function() { return {}; }, GetDisabledTechnologies: function() { return {}; }, GetSpyCostMultiplier: function() { return 1; }, HasSharedDropsites: function() { return false; }, HasSharedLos: function() { return false; } }); AddMock(101, IID_EntityLimits, { GetLimits: function() { return {"Bar": 20}; }, GetCounts: function() { return {"Bar": 0}; }, GetLimitChangers: function() {return {"Bar": {}}; } }); AddMock(101, IID_TechnologyManager, { "IsTechnologyResearched": tech => tech == "phase_village", "GetQueuedResearch": () => new Map(), "GetStartedTechs": () => new Set(), "GetResearchedTechs": () => new Set(), "GetClassCounts": () => ({}), "GetTypeCountsByClass": () => ({}) }); AddMock(101, IID_StatisticsTracker, { GetBasicStatistics: function() { return { "resourcesGathered": { "food": 100, "wood": 0, "metal": 0, "stone": 0, "vegetarianFood": 0 }, "percentMapExplored": 10 }; }, GetSequences: function() { return { "unitsTrained": [0, 10], "unitsLost": [0, 9], "buildingsConstructed": [0, 5], "buildingsCaptured": [0, 7], "buildingsLost": [0, 4], "civCentresBuilt": [0, 1], "resourcesGathered": { "food": [0, 100], "wood": [0, 0], "metal": [0, 0], "stone": [0, 0], "vegetarianFood": [0, 0] }, "treasuresCollected": [0, 0], "lootCollected": [0, 0], "percentMapExplored": [0, 10], "teamPercentMapExplored": [0, 10], "percentMapControlled": [0, 10], "teamPercentMapControlled": [0, 10], "peakPercentOfMapControlled": [0, 10], "teamPeakPercentOfMapControlled": [0, 10] }; }, IncreaseTrainedUnitsCounter: function() { return 1; }, IncreaseConstructedBuildingsCounter: function() { return 1; }, IncreaseBuiltCivCentresCounter: function() { return 1; } }); // Note: property order matters when using TS_ASSERT_UNEVAL_EQUALS, // because uneval preserves property order. So make sure this object // matches the ordering in GuiInterface. TS_ASSERT_UNEVAL_EQUALS(cmp.GetSimulationState(), { players: [ { name: "Player 1", civ: "gaia", color: { r:1, g:1, b:1, a:1 }, controlsAll: false, popCount: 10, popLimit: 20, popMax: 200, panelEntities: [], resourceCounts: { food: 100 }, trainingBlocked: false, state: "active", team: -1, teamsLocked: false, cheatsEnabled: false, disabledTemplates: {}, disabledTechnologies: {}, hasSharedDropsites: false, hasSharedLos: false, spyCostMultiplier: 1, phase: "village", isAlly: [false, false], isMutualAlly: [false, false], isNeutral: [false, false], isEnemy: [true, true], entityLimits: {"Foo": 10}, entityCounts: {"Foo": 5}, entityLimitChangers: {"Foo": {}}, researchQueued: new Map(), researchStarted: new Set(), researchedTechs: new Set(), classCounts: {}, typeCountsByClass: {}, canBarter: false, barterPrices: { "buy": { "food": 150 }, "sell": { "food": 25 } }, statistics: { resourcesGathered: { food: 100, wood: 0, metal: 0, stone: 0, vegetarianFood: 0 }, percentMapExplored: 10 } }, { name: "Player 2", civ: "mace", color: { r:1, g:0, b:0, a:1 }, controlsAll: true, popCount: 40, popLimit: 30, popMax: 300, panelEntities: [], resourceCounts: { food: 200 }, trainingBlocked: false, state: "active", team: -1, teamsLocked: false, cheatsEnabled: false, disabledTemplates: {}, disabledTechnologies: {}, hasSharedDropsites: false, hasSharedLos: false, spyCostMultiplier: 1, phase: "village", isAlly: [true, true], isMutualAlly: [false, false], isNeutral: [false, false], isEnemy: [false, false], entityLimits: {"Bar": 20}, entityCounts: {"Bar": 0}, entityLimitChangers: {"Bar": {}}, researchQueued: new Map(), researchStarted: new Set(), researchedTechs: new Set(), classCounts: {}, typeCountsByClass: {}, canBarter: false, barterPrices: { "buy": { "food": 150 }, "sell": { "food": 25 } }, statistics: { resourcesGathered: { food: 100, wood: 0, metal: 0, stone: 0, vegetarianFood: 0 }, percentMapExplored: 10 } } ], circularMap: false, timeElapsed: 0, "victoryConditions": ["conquest", "wonder"], alliedVictory: false }); TS_ASSERT_UNEVAL_EQUALS(cmp.GetExtendedSimulationState(), { "players": [ { "name": "Player 1", "civ": "gaia", "color": { "r":1, "g":1, "b":1, "a":1 }, "controlsAll": false, "popCount": 10, "popLimit": 20, "popMax": 200, "panelEntities": [], "resourceCounts": { "food": 100 }, "trainingBlocked": false, "state": "active", "team": -1, "teamsLocked": false, "cheatsEnabled": false, "disabledTemplates": {}, "disabledTechnologies": {}, "hasSharedDropsites": false, "hasSharedLos": false, "spyCostMultiplier": 1, "phase": "village", "isAlly": [false, false], "isMutualAlly": [false, false], "isNeutral": [false, false], "isEnemy": [true, true], "entityLimits": {"Foo": 10}, "entityCounts": {"Foo": 5}, "entityLimitChangers": {"Foo": {}}, "researchQueued": new Map(), "researchStarted": new Set(), "researchedTechs": new Set(), "classCounts": {}, "typeCountsByClass": {}, "canBarter": false, "barterPrices": { "buy": { "food": 150 }, "sell": { "food": 25 } }, "statistics": { "resourcesGathered": { "food": 100, "wood": 0, "metal": 0, "stone": 0, "vegetarianFood": 0 }, "percentMapExplored": 10 }, "sequences": { "unitsTrained": [0, 10], "unitsLost": [0, 42], "buildingsConstructed": [1, 3], "buildingsCaptured": [3, 7], "buildingsLost": [3, 10], "civCentresBuilt": [4, 10], "resourcesGathered": { "food": [5, 100], "wood": [0, 0], "metal": [0, 0], "stone": [0, 0], "vegetarianFood": [0, 0] }, "treasuresCollected": [1, 20], "lootCollected": [0, 2], "percentMapExplored": [0, 10], "teamPercentMapExplored": [0, 10], "percentMapControlled": [0, 10], "teamPercentMapControlled": [0, 10], "peakPercentOfMapControlled": [0, 10], "teamPeakPercentOfMapControlled": [0, 10] } }, { "name": "Player 2", "civ": "mace", "color": { "r":1, "g":0, "b":0, "a":1 }, "controlsAll": true, "popCount": 40, "popLimit": 30, "popMax": 300, "panelEntities": [], "resourceCounts": { "food": 200 }, "trainingBlocked": false, "state": "active", "team": -1, "teamsLocked": false, "cheatsEnabled": false, "disabledTemplates": {}, "disabledTechnologies": {}, "hasSharedDropsites": false, "hasSharedLos": false, "spyCostMultiplier": 1, "phase": "village", "isAlly": [true, true], "isMutualAlly": [false, false], "isNeutral": [false, false], "isEnemy": [false, false], "entityLimits": {"Bar": 20}, "entityCounts": {"Bar": 0}, "entityLimitChangers": {"Bar": {}}, "researchQueued": new Map(), "researchStarted": new Set(), "researchedTechs": new Set(), "classCounts": {}, "typeCountsByClass": {}, "canBarter": false, "barterPrices": { "buy": { "food": 150 }, "sell": { "food": 25 } }, "statistics": { "resourcesGathered": { "food": 100, "wood": 0, "metal": 0, "stone": 0, "vegetarianFood": 0 }, "percentMapExplored": 10 }, "sequences": { "unitsTrained": [0, 10], "unitsLost": [0, 9], "buildingsConstructed": [0, 5], "buildingsCaptured": [0, 7], "buildingsLost": [0, 4], "civCentresBuilt": [0, 1], "resourcesGathered": { "food": [0, 100], "wood": [0, 0], "metal": [0, 0], "stone": [0, 0], "vegetarianFood": [0, 0] }, "treasuresCollected": [0, 0], "lootCollected": [0, 0], "percentMapExplored": [0, 10], "teamPercentMapExplored": [0, 10], "percentMapControlled": [0, 10], "teamPercentMapControlled": [0, 10], "peakPercentOfMapControlled": [0, 10], "teamPeakPercentOfMapControlled": [0, 10] } } ], "circularMap": false, "timeElapsed": 0, "victoryConditions": ["conquest", "wonder"], "alliedVictory": false }); AddMock(10, IID_Builder, { GetEntitiesList: function() { return ["test1", "test2"]; }, }); AddMock(10, IID_Health, { GetHitpoints: function() { return 50; }, GetMaxHitpoints: function() { return 60; }, IsRepairable: function() { return false; }, IsUnhealable: function() { return false; } }); AddMock(10, IID_Identity, { GetClassesList: function() { return ["class1", "class2"]; }, GetVisibleClassesList: function() { return ["class3", "class4"]; }, GetRank: function() { return "foo"; }, GetSelectionGroupName: function() { return "Selection Group Name"; }, HasClass: function() { return true; }, IsUndeletable: function() { return false; } }); AddMock(10, IID_Position, { GetTurretParent: function() {return INVALID_ENTITY;}, GetPosition: function() { return {x:1, y:2, z:3}; }, IsInWorld: function() { return true; } }); AddMock(10, IID_ResourceTrickle, { "GetTimer": () => 1250, "GetRates": () => ({ "food": 2, "wood": 3, "stone": 5, "metal": 9 }) }); // Note: property order matters when using TS_ASSERT_UNEVAL_EQUALS, // because uneval preserves property order. So make sure this object // matches the ordering in GuiInterface. TS_ASSERT_UNEVAL_EQUALS(cmp.GetEntityState(-1, 10), { "id": 10, "player": INVALID_PLAYER, "template": "example", "identity": { "rank": "foo", "classes": ["class1", "class2"], "visibleClasses": ["class3", "class4"], "selectionGroupName": "Selection Group Name", "canDelete": true }, "position": {x:1, y:2, z:3}, "hitpoints": 50, "maxHitpoints": 60, "needsRepair": false, "needsHeal": true, "builder": true, "canGarrison": false, "visibility": "visible", "isBarterMarket":true, "resourceTrickle": { "interval": 1250, "rates": { "food": 2, "wood": 3, "stone": 5, "metal": 9 } } }); Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Health.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Health.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Health.js (revision 22754) @@ -1,150 +1,150 @@ Engine.LoadComponentScript("interfaces/TechnologyManager.js"); Engine.LoadComponentScript("interfaces/AuraManager.js"); Engine.LoadHelperScript("Player.js"); Engine.LoadHelperScript("ValueModification.js"); Engine.LoadHelperScript("Sound.js"); Engine.LoadComponentScript("interfaces/DeathDamage.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("Health.js"); const entity_id = 5; const corpse_id = entity_id + 1; const health_template = { "Max": 50, "RegenRate": 0, "IdleRegenRate": 0, "DeathType": "corpse", "Unhealable": false }; var injured_flag = false; var corpse_entity; function setEntityUp() { let cmpHealth = ConstructComponent(entity_id, "Health", health_template); AddMock(entity_id, IID_DeathDamage, { "CauseDeathDamage": () => {} }); AddMock(entity_id, IID_Position, { "IsInWorld": () => true, "GetPosition": () => ({ "x": 0, "z": 0 }), "GetRotation": () => ({ "x": 0, "y": 0, "z": 0 }) }); AddMock(entity_id, IID_Ownership, { "GetOwner": () => 1 }); AddMock(entity_id, IID_Visual, { "GetActorSeed": () => 1 }); AddMock(SYSTEM_ENTITY, IID_TemplateManager, { "GetCurrentTemplateName": () => "test" }); AddMock(SYSTEM_ENTITY, IID_RangeManager, { "SetEntityFlag": (ent, flag, value) => (injured_flag = value) }); return cmpHealth; } var cmpHealth = setEntityUp(); TS_ASSERT_EQUALS(cmpHealth.GetHitpoints(), 50); TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), 50); TS_ASSERT_EQUALS(cmpHealth.IsInjured(), false); TS_ASSERT_EQUALS(cmpHealth.IsUnhealable(), true); var change = cmpHealth.Reduce(25); TS_ASSERT_EQUALS(injured_flag, true); TS_ASSERT_EQUALS(change.killed, false); -TS_ASSERT_EQUALS(change.change, -25); +TS_ASSERT_EQUALS(change.HPchange, -25); TS_ASSERT_EQUALS(cmpHealth.GetHitpoints(), 25); TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), 50); TS_ASSERT_EQUALS(cmpHealth.IsInjured(), true); TS_ASSERT_EQUALS(cmpHealth.IsUnhealable(), false); change = cmpHealth.Increase(25); TS_ASSERT_EQUALS(injured_flag, false); TS_ASSERT_EQUALS(change.new, 50); TS_ASSERT_EQUALS(cmpHealth.GetHitpoints(), 50); TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), 50); TS_ASSERT_EQUALS(cmpHealth.IsInjured(), false); TS_ASSERT_EQUALS(cmpHealth.IsUnhealable(), true); // Check death. Engine.AddLocalEntity = function(template) { corpse_entity = template; AddMock(corpse_id, IID_Position, { "JumpTo": () => {}, "SetYRotation": () => {}, "SetXZRotation": () => {}, }); AddMock(corpse_id, IID_Ownership, { "SetOwner": () => {}, }); AddMock(corpse_id, IID_Visual, { "SetActorSeed": () => {}, "SelectAnimation": () => {}, }); return corpse_id; }; change = cmpHealth.Reduce(50); // Assert we create a corpse with the proper template. TS_ASSERT_EQUALS(corpse_entity, "corpse|test"); // Check that we are not marked as injured. TS_ASSERT_EQUALS(injured_flag, false); TS_ASSERT_EQUALS(change.killed, true); -TS_ASSERT_EQUALS(change.change, -50); +TS_ASSERT_EQUALS(change.HPchange, -50); TS_ASSERT_EQUALS(cmpHealth.GetHitpoints(), 0); TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), 50); TS_ASSERT_EQUALS(cmpHealth.IsInjured(), false); // Check that we can't be revived once dead. change = cmpHealth.Increase(25); TS_ASSERT_EQUALS(change.new, 0); TS_ASSERT_EQUALS(cmpHealth.GetHitpoints(), 0); TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), 50); TS_ASSERT_EQUALS(cmpHealth.IsInjured(), false); // Check that we can't die twice. change = cmpHealth.Reduce(50); TS_ASSERT_EQUALS(change.killed, false); -TS_ASSERT_EQUALS(change.change, 0); +TS_ASSERT_EQUALS(change.HPchange, 0); TS_ASSERT_EQUALS(cmpHealth.GetHitpoints(), 0); TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), 50); TS_ASSERT_EQUALS(cmpHealth.IsInjured(), false); cmpHealth = setEntityUp(); // Check that we still die with > Max HP of damage. change = cmpHealth.Reduce(60); TS_ASSERT_EQUALS(change.killed, true); -TS_ASSERT_EQUALS(change.change, -50); +TS_ASSERT_EQUALS(change.HPchange, -50); TS_ASSERT_EQUALS(cmpHealth.GetHitpoints(), 0); TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), 50); TS_ASSERT_EQUALS(cmpHealth.IsInjured(), false); cmpHealth = setEntityUp(); // Check that increasing by more than required puts us at the max HP change = cmpHealth.Reduce(30); change = cmpHealth.Increase(30); TS_ASSERT_EQUALS(injured_flag, false); TS_ASSERT_EQUALS(change.new, 50); TS_ASSERT_EQUALS(cmpHealth.GetHitpoints(), 50); TS_ASSERT_EQUALS(cmpHealth.GetMaxHitpoints(), 50); TS_ASSERT_EQUALS(cmpHealth.IsInjured(), false); TS_ASSERT_EQUALS(cmpHealth.IsUnhealable(), true); Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_UnitAI.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_UnitAI.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_UnitAI.js (revision 22754) @@ -1,329 +1,329 @@ Engine.LoadHelperScript("FSM.js"); Engine.LoadHelperScript("Entity.js"); Engine.LoadHelperScript("Player.js"); Engine.LoadComponentScript("interfaces/Attack.js"); Engine.LoadComponentScript("interfaces/Auras.js"); Engine.LoadComponentScript("interfaces/BuildingAI.js"); Engine.LoadComponentScript("interfaces/Capturable.js"); -Engine.LoadComponentScript("interfaces/DamageReceiver.js"); +Engine.LoadComponentScript("interfaces/Resistance.js"); Engine.LoadComponentScript("interfaces/Formation.js"); Engine.LoadComponentScript("interfaces/Heal.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/Pack.js"); Engine.LoadComponentScript("interfaces/ResourceSupply.js"); Engine.LoadComponentScript("interfaces/ResourceGatherer.js"); Engine.LoadComponentScript("interfaces/Timer.js"); Engine.LoadComponentScript("interfaces/UnitAI.js"); Engine.LoadComponentScript("Formation.js"); Engine.LoadComponentScript("UnitAI.js"); /* Regression test. * Tests the FSM behaviour of a unit when walking as part of a formation, * then exiting the formation. * mode == 0: There is no enemy unit nearby. * mode == 1: There is a live enemy unit nearby. * mode == 2: There is a dead enemy unit nearby. */ function TestFormationExiting(mode) { ResetState(); var playerEntity = 5; var unit = 10; var enemy = 20; var controller = 30; AddMock(SYSTEM_ENTITY, IID_Timer, { SetInterval: function() { }, SetTimeout: function() { }, }); AddMock(SYSTEM_ENTITY, IID_RangeManager, { CreateActiveQuery: function(ent, minRange, maxRange, players, iid, flags) { return 1; }, EnableActiveQuery: function(id) { }, ResetActiveQuery: function(id) { if (mode == 0) return []; else return [enemy]; }, DisableActiveQuery: function(id) { }, GetEntityFlagMask: function(identifier) { }, }); AddMock(SYSTEM_ENTITY, IID_TemplateManager, { GetCurrentTemplateName: function(ent) { return "special/formations/line_closed"; }, }); AddMock(SYSTEM_ENTITY, IID_PlayerManager, { GetPlayerByID: function(id) { return playerEntity; }, GetNumPlayers: function() { return 2; }, }); AddMock(playerEntity, IID_Player, { IsAlly: function() { return false; }, IsEnemy: function() { return true; }, GetEnemies: function() { return []; }, }); AddMock(SYSTEM_ENTITY, IID_ObstructionManager, { "IsInTargetRange": (ent, target, min, max, opposite) => true }); var unitAI = ConstructComponent(unit, "UnitAI", { "FormationController": "false", "DefaultStance": "aggressive" }); AddMock(unit, IID_Identity, { GetClassesList: function() { return []; }, }); AddMock(unit, IID_Ownership, { GetOwner: function() { return 1; }, }); AddMock(unit, IID_Position, { GetTurretParent: function() { return INVALID_ENTITY; }, GetPosition: function() { return new Vector3D(); }, GetPosition2D: function() { return new Vector2D(); }, GetRotation: function() { return { "y": 0 }; }, IsInWorld: function() { return true; }, }); AddMock(unit, IID_UnitMotion, { "GetWalkSpeed": () => 1, "MoveToFormationOffset": (target, x, z) => {}, "MoveToTargetRange": (target, min, max) => true, "StopMoving": () => {}, "GetPassabilityClassName": () => "default" }); AddMock(unit, IID_Vision, { GetRange: function() { return 10; }, }); AddMock(unit, IID_Attack, { GetRange: function() { return { "max": 10, "min": 0}; }, GetFullAttackRange: function() { return { "max": 40, "min": 0}; }, GetBestAttackAgainst: function(t) { return "melee"; }, GetPreference: function(t) { return 0; }, GetTimers: function() { return { "prepare": 500, "repeat": 1000 }; }, CanAttack: function(v) { return true; }, CompareEntitiesByPreference: function(a, b) { return 0; }, }); unitAI.OnCreate(); unitAI.SetupRangeQuery(1); if (mode == 1) { AddMock(enemy, IID_Health, { GetHitpoints: function() { return 10; }, }); AddMock(enemy, IID_UnitAI, { IsAnimal: function() { return false; } }); } else if (mode == 2) AddMock(enemy, IID_Health, { GetHitpoints: function() { return 0; }, }); var controllerFormation = ConstructComponent(controller, "Formation", {"FormationName": "Line Closed", "FormationShape": "square", "ShiftRows": "false", "SortingClasses": "", "WidthDepthRatio": 1, "UnitSeparationWidthMultiplier": 1, "UnitSeparationDepthMultiplier": 1, "SpeedMultiplier": 1, "Sloppyness": 0}); var controllerAI = ConstructComponent(controller, "UnitAI", { "FormationController": "true", "DefaultStance": "aggressive" }); AddMock(controller, IID_Position, { JumpTo: function(x, z) { this.x = x; this.z = z; }, GetTurretParent: function() { return INVALID_ENTITY; }, GetPosition: function() { return new Vector3D(this.x, 0, this.z); }, GetPosition2D: function() { return new Vector2D(this.x, this.z); }, GetRotation: function() { return { "y": 0 }; }, IsInWorld: function() { return true; }, }); AddMock(controller, IID_UnitMotion, { "GetWalkSpeed": () => 1, "StopMoving": () => {}, "SetSpeedMultiplier": () => {}, "MoveToPointRange": () => true, "GetPassabilityClassName": () => "default" }); controllerAI.OnCreate(); TS_ASSERT_EQUALS(controllerAI.fsmStateName, "FORMATIONCONTROLLER.IDLE"); TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.IDLE"); controllerFormation.SetMembers([unit]); controllerAI.Walk(100, 100, false); TS_ASSERT_EQUALS(controllerAI.fsmStateName, "FORMATIONCONTROLLER.WALKING"); TS_ASSERT_EQUALS(unitAI.fsmStateName, "FORMATIONMEMBER.WALKING"); controllerFormation.Disband(); unitAI.UnitFsm.ProcessMessage(unitAI, { "type": "Timer" }); if (mode == 0) TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.IDLE"); else if (mode == 1) TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.COMBAT.ATTACKING"); else if (mode == 2) TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.IDLE"); else TS_FAIL("invalid mode"); } function TestMoveIntoFormationWhileAttacking() { ResetState(); var playerEntity = 5; var controller = 10; var enemy = 20; var unit = 30; var units = []; var unitCount = 8; var unitAIs = []; AddMock(SYSTEM_ENTITY, IID_Timer, { SetInterval: function() { }, SetTimeout: function() { }, }); AddMock(SYSTEM_ENTITY, IID_RangeManager, { CreateActiveQuery: function(ent, minRange, maxRange, players, iid, flags) { return 1; }, EnableActiveQuery: function(id) { }, ResetActiveQuery: function(id) { return [enemy]; }, DisableActiveQuery: function(id) { }, GetEntityFlagMask: function(identifier) { }, }); AddMock(SYSTEM_ENTITY, IID_TemplateManager, { GetCurrentTemplateName: function(ent) { return "special/formations/line_closed"; }, }); AddMock(SYSTEM_ENTITY, IID_PlayerManager, { GetPlayerByID: function(id) { return playerEntity; }, GetNumPlayers: function() { return 2; }, }); AddMock(SYSTEM_ENTITY, IID_ObstructionManager, { "IsInTargetRange": (ent, target, min, max) => true }); AddMock(playerEntity, IID_Player, { IsAlly: function() { return false; }, IsEnemy: function() { return true; }, GetEnemies: function() { return []; }, }); // create units for (var i = 0; i < unitCount; i++) { units.push(unit + i); var unitAI = ConstructComponent(unit + i, "UnitAI", { "FormationController": "false", "DefaultStance": "aggressive" }); AddMock(unit + i, IID_Identity, { GetClassesList: function() { return []; }, }); AddMock(unit + i, IID_Ownership, { GetOwner: function() { return 1; }, }); AddMock(unit + i, IID_Position, { GetTurretParent: function() { return INVALID_ENTITY; }, GetPosition: function() { return new Vector3D(); }, GetPosition2D: function() { return new Vector2D(); }, GetRotation: function() { return { "y": 0 }; }, IsInWorld: function() { return true; }, }); AddMock(unit + i, IID_UnitMotion, { "GetWalkSpeed": () => 1, "MoveToFormationOffset": (target, x, z) => {}, "MoveToTargetRange": (target, min, max) => true, "StopMoving": () => {}, "GetPassabilityClassName": () => "default" }); AddMock(unit + i, IID_Vision, { GetRange: function() { return 10; }, }); AddMock(unit + i, IID_Attack, { GetRange: function() { return {"max":10, "min": 0}; }, GetFullAttackRange: function() { return { "max": 40, "min": 0}; }, GetBestAttackAgainst: function(t) { return "melee"; }, GetTimers: function() { return { "prepare": 500, "repeat": 1000 }; }, CanAttack: function(v) { return true; }, CompareEntitiesByPreference: function(a, b) { return 0; }, }); unitAI.OnCreate(); unitAI.SetupRangeQuery(1); unitAIs.push(unitAI); } // create enemy AddMock(enemy, IID_Health, { GetHitpoints: function() { return 40; }, }); var controllerFormation = ConstructComponent(controller, "Formation", {"FormationName": "Line Closed", "FormationShape": "square", "ShiftRows": "false", "SortingClasses": "", "WidthDepthRatio": 1, "UnitSeparationWidthMultiplier": 1, "UnitSeparationDepthMultiplier": 1, "SpeedMultiplier": 1, "Sloppyness": 0}); var controllerAI = ConstructComponent(controller, "UnitAI", { "FormationController": "true", "DefaultStance": "aggressive" }); AddMock(controller, IID_Position, { GetTurretParent: function() { return INVALID_ENTITY; }, JumpTo: function(x, z) { this.x = x; this.z = z; }, GetPosition: function() { return new Vector3D(this.x, 0, this.z); }, GetPosition2D: function() { return new Vector2D(this.x, this.z); }, GetRotation: function() { return { "y": 0 }; }, IsInWorld: function() { return true; }, }); AddMock(controller, IID_UnitMotion, { "GetWalkSpeed": () => 1, "SetSpeedMultiplier": (speed) => {}, "MoveToPointRange": (x, z, minRange, maxRange) => {}, "StopMoving": () => {}, "GetPassabilityClassName": () => "default" }); AddMock(controller, IID_Attack, { GetRange: function() { return {"max":10, "min": 0}; }, CanAttackAsFormation: function() { return false; }, }); controllerAI.OnCreate(); controllerFormation.SetMembers(units); controllerAI.Attack(enemy, []); for (var ent of unitAIs) TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.COMBAT.ATTACKING"); controllerAI.MoveIntoFormation({"name": "Circle"}); // let all units be in position for (var ent of unitAIs) controllerFormation.SetInPosition(ent); for (var ent of unitAIs) TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.COMBAT.ATTACKING"); controllerFormation.Disband(); } TestFormationExiting(0); TestFormationExiting(1); TestFormationExiting(2); TestMoveIntoFormationWhileAttacking(); Index: ps/trunk/binaries/data/mods/public/simulation/helpers/DamageBonus.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/helpers/DamageBonus.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/helpers/DamageBonus.js (revision 22754) @@ -1,31 +1,31 @@ /** * Calculates the attack damage multiplier against a target. * @param {entity_id_t} source - The source entity's id. * @param {entity_id_t} target - The target entity's id. * @param {string} type - The type of attack. * @param {Object} template - The bonus' template. * @return {number} - The source entity's attack bonus against the specified target. */ -function GetDamageBonus(source, target, type, template) +function GetAttackBonus(source, target, type, template) { let attackBonus = 1; let cmpIdentity = Engine.QueryInterface(target, IID_Identity); if (!cmpIdentity) return 1; // Multiply the bonuses for all matching classes for (let key in template) { let bonus = template[key]; if (bonus.Civ && bonus.Civ !== cmpIdentity.GetCiv()) continue; if (bonus.Classes && bonus.Classes.split(/\s+/).some(cls => !cmpIdentity.HasClass(cls))) continue; attackBonus *= ApplyValueModificationsToEntity("Attack/" + type + "/Bonuses/" + key + "/Multiplier", +bonus.Multiplier, source); } return attackBonus; } -Engine.RegisterGlobal("GetDamageBonus", GetDamageBonus); +Engine.RegisterGlobal("GetAttackBonus", GetAttackBonus); Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion.xml (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion.xml (revision 22754) @@ -1,30 +1,30 @@ - 5 + 5 4 1000 Field Palisade SiegeWall StoneWall Champion Unit Organic Human Champion Soldier phase_city 150 10 10 0 20 attack/impact/arrow_metal.xml attack/weapon/arrowfly.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry.xml (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry.xml (revision 22754) @@ -1,128 +1,128 @@ 2 4 15 - 2 + 2 4 1000 Field Palisade SiegeWall StoneWall 50.0 0.0 0.0 2 1.0 structures/{civ}_house structures/{civ}_storehouse structures/{civ}_farmstead structures/{civ}_field structures/{civ}_corral structures/{civ}_outpost other/wallset_palisade structures/{civ}_sentry_tower structures/{civ}_dock structures/{civ}_barracks structures/{civ}_blacksmith structures/{civ}_temple structures/{civ}_market structures/{civ}_defense_tower structures/{civ}_wallset_stone structures/{civ}_civil_centre structures/{civ}_fortress structures/{civ}_wonder 10 50 0 0 0 2.5 80 Infantry CitizenSoldier Human Organic Citizen Infantry Soldier Worker Basic 100 5 0 0 0 100 2.0 1.0 0.5 0.25 1 0.75 5 0.5 2 0.5 circle/128x128.png circle/128x128_mask.png voice/{lang}/civ/civ_{phenotype}_walk.xml voice/{lang}/civ/civ_{phenotype}_attack.xml voice/{lang}/civ/civ_{phenotype}_gather.xml voice/{lang}/civ/civ_{phenotype}_repair.xml voice/{lang}/civ/civ_{phenotype}_garrison.xml actor/human/movement/walk.xml actor/human/movement/run.xml attack/impact/arrow_metal.xml attack/weapon/sword.xml attack/weapon/arrowfly.xml actor/human/death/{phenotype}_death.xml resource/construction/con_wood.xml resource/foraging/forage_leaves.xml resource/farming/farm.xml resource/lumbering/lumbering.xml resource/mining/pickaxe.xml resource/mining/mining.xml resource/mining/mining.xml interface/alarm/alarm_create_infantry.xml 80 Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_StatusEffectsReceiver.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_StatusEffectsReceiver.js (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_StatusEffectsReceiver.js (revision 22754) @@ -1,119 +1,117 @@ -Engine.LoadComponentScript("interfaces/Damage.js"); Engine.LoadComponentScript("interfaces/StatusEffectsReceiver.js"); Engine.LoadComponentScript("interfaces/Timer.js"); Engine.LoadComponentScript("StatusEffectsReceiver.js"); Engine.LoadComponentScript("Timer.js"); var target = 42; var cmpStatusReceiver; var cmpTimer; var dealtDamage; function setup() { cmpStatusReceiver = ConstructComponent(target, "StatusEffectsReceiver"); cmpTimer = ConstructComponent(SYSTEM_ENTITY, "Timer"); dealtDamage = 0; } function testInflictEffects() { setup(); let statusName = "Burn"; - AddMock(SYSTEM_ENTITY, IID_Damage, { - "CauseDamage": (data) => { dealtDamage += data.strengths[statusName]; } - }); + let Attacking = { + "HandleAttackEffects": (_, attackData) => { dealtDamage += attackData.Damage[statusName]; } + }; + Engine.RegisterGlobal("Attacking", Attacking); // damage scheduled: 0, 10, 20 sec - cmpStatusReceiver.InflictEffects({ - [statusName]: { - "Duration": 20000, - "Interval": 10000, - "Damage": 1 - } + cmpStatusReceiver.AddStatus(statusName, { + "Duration": 20000, + "Interval": 10000, + "Damage": 1 }); cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 1); // 1 sec cmpTimer.OnUpdate({ "turnLength": 8 }); TS_ASSERT_EQUALS(dealtDamage, 1); // 9 sec cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 2); // 10 sec cmpTimer.OnUpdate({ "turnLength": 10 }); TS_ASSERT_EQUALS(dealtDamage, 3); // 20 sec cmpTimer.OnUpdate({ "turnLength": 10 }); TS_ASSERT_EQUALS(dealtDamage, 3); // 30 sec } testInflictEffects(); function testMultipleEffects() { setup(); - AddMock(SYSTEM_ENTITY, IID_Damage, { - "CauseDamage": (data) => { - if (data.strengths.Burn) dealtDamage += data.strengths.Burn; - if (data.strengths.Poison) dealtDamage += data.strengths.Poison; - } - }); + let Attacking = { + "HandleAttackEffects": (_, attackData) => { + if (attackData.Damage.Burn) dealtDamage += attackData.Damage.Burn; + if (attackData.Damage.Poison) dealtDamage += attackData.Damage.Poison; + }, + }; + Engine.RegisterGlobal("Attacking", Attacking); // damage scheduled: 0, 1, 2, 10 sec - cmpStatusReceiver.InflictEffects({ + cmpStatusReceiver.GiveStatus({ "Burn": { "Duration": 20000, "Interval": 10000, "Damage": 10 }, "Poison": { "Duration": 3000, "Interval": 1000, "Damage": 1 } }); cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 12); // 1 sec cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 13); // 2 sec cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 13); // 3 sec cmpTimer.OnUpdate({ "turnLength": 7 }); TS_ASSERT_EQUALS(dealtDamage, 23); // 10 sec } testMultipleEffects(); -function testRemoveEffect() +function testRemoveStatus() { setup(); let statusName = "Poison"; - AddMock(SYSTEM_ENTITY, IID_Damage, { - "CauseDamage": (data) => { dealtDamage += data.strengths[statusName]; } - }); + let Attacking = { + "HandleAttackEffects": (_, attackData) => { dealtDamage += attackData.Damage[statusName]; } + }; + Engine.RegisterGlobal("Attacking", Attacking); // damage scheduled: 0, 10, 20 sec - cmpStatusReceiver.InflictEffects({ - [statusName]: { - "Duration": 20000, - "Interval": 10000, - "Damage": 1 - } + cmpStatusReceiver.AddStatus(statusName, { + "Duration": 20000, + "Interval": 10000, + "Damage": 1 }); cmpTimer.OnUpdate({ "turnLength": 1 }); TS_ASSERT_EQUALS(dealtDamage, 1); // 1 sec - cmpStatusReceiver.RemoveEffect(statusName); + cmpStatusReceiver.RemoveStatus(statusName); cmpTimer.OnUpdate({ "turnLength": 10 }); TS_ASSERT_EQUALS(dealtDamage, 1); // 11 sec } -testRemoveEffect(); +testRemoveStatus(); Index: ps/trunk/binaries/data/mods/public/simulation/helpers/Attacking.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/helpers/Attacking.js (nonexistent) +++ ps/trunk/binaries/data/mods/public/simulation/helpers/Attacking.js (revision 22754) @@ -0,0 +1,308 @@ +/** + * Provides attack and damage-related helpers under the Attacking umbrella (to avoid name ambiguity with the component). + */ +function Attacking() {} + +/** + * Builds a RelaxRNG schema of possible attack effects. + * Currently harcoded to "Damage", "Capture" and "StatusEffects". + * Attacks may also have a "Bonuses" element. + * + * @return {string} - RelaxNG schema string + */ +Attacking.prototype.BuildAttackEffectsSchema = function() +{ + return "" + + "" + + "" + + "" + + "" + + "" + + "" + + // Armour requires Foundation to not be a damage type. + "Foundation" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; +}; + +/** + * Returns a template-like object of attack effects. + */ +Attacking.prototype.GetAttackEffectsData = function(valueModifRoot, template, entity) +{ + let ret = {}; + + if (template.Damage) + { + ret.Damage = {}; + let applyMods = damageType => + ApplyValueModificationsToEntity(valueModifRoot + "/Damage/" + damageType, +(template.Damage[damageType] || 0), entity); + for (let damageType in template.Damage) + ret.Damage[damageType] = applyMods(damageType); + } + if (template.Capture) + ret.Capture = ApplyValueModificationsToEntity(valueModifRoot + "/Capture", +(template.Capture || 0), entity); + + if (template.StatusEffects) + ret.StatusEffects = template.StatusEffects; + + if (template.Bonuses) + ret.Bonuses = template.Bonuses; + + return ret; +}; + +Attacking.prototype.GetTotalAttackEffects = function(effectData, effectType, cmpResistance) +{ + let total = 0; + let armourStrengths = cmpResistance ? cmpResistance.GetArmourStrengths(effectType) : {}; + + for (let type in effectData) + total += effectData[type] * Math.pow(0.9, armourStrengths[type] || 0); + + return total; +}; + +/** + * Gives the position of the given entity, taking the lateness into account. + * @param {number} ent - Entity id of the entity we are finding the location for. + * @param {number} lateness - The time passed since the expected time to fire the function. + * @return {Vector3D} The location of the entity. + */ +Attacking.prototype.InterpolatedLocation = function(ent, lateness) +{ + let cmpTargetPosition = Engine.QueryInterface(ent, IID_Position); + if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) // TODO: handle dead target properly + return undefined; + let curPos = cmpTargetPosition.GetPosition(); + let prevPos = cmpTargetPosition.GetPreviousPosition(); + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + let turnLength = cmpTimer.GetLatestTurnLength(); + return new Vector3D( + (curPos.x * (turnLength - lateness) + prevPos.x * lateness) / turnLength, + 0, + (curPos.z * (turnLength - lateness) + prevPos.z * lateness) / turnLength + ); +}; + +/** + * Test if a point is inside of an entity's footprint. + * @param {number} ent - Id of the entity we are checking with. + * @param {Vector3D} point - The point we are checking with. + * @param {number} lateness - The time passed since the expected time to fire the function. + * @return {boolean} True if the point is inside of the entity's footprint. + */ +Attacking.prototype.TestCollision = function(ent, point, lateness) +{ + let targetPosition = this.InterpolatedLocation(ent, lateness); + if (!targetPosition) + return false; + + let cmpFootprint = Engine.QueryInterface(ent, IID_Footprint); + if (!cmpFootprint) + return false; + + let targetShape = cmpFootprint.GetShape(); + + if (!targetShape) + return false; + + if (targetShape.type == "circle") + return targetPosition.horizDistanceToSquared(point) < targetShape.radius * targetShape.radius; + + if (targetShape.type == "square") + { + let angle = Engine.QueryInterface(ent, IID_Position).GetRotation().y; + let distance = Vector2D.from3D(Vector3D.sub(point, targetPosition)).rotate(-angle); + return Math.abs(distance.x) < targetShape.width / 2 && Math.abs(distance.y) < targetShape.depth / 2; + } + + warn("TestCollision called with an invalid footprint shape"); + return false; +}; + +/** + * Get the list of players affected by the damage. + * @param {number} attackerOwner - The player id of the attacker. + * @param {boolean} friendlyFire - A flag indicating if allied entities are also damaged. + * @return {number[]} The ids of players need to be damaged. + */ +Attacking.prototype.GetPlayersToDamage = function(attackerOwner, friendlyFire) +{ + if (!friendlyFire) + return QueryPlayerIDInterface(attackerOwner).GetEnemies(); + + return Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetAllPlayers(); +}; + +/** + * Damages units around a given origin. + * @param {Object} data - The data sent by the caller. + * @param {string} data.type - The type of damage. + * @param {Object} data.attackData - The attack data. + * @param {number} data.attacker - The entity id of the attacker. + * @param {number} data.attackerOwner - The player id of the attacker. + * @param {Vector2D} data.origin - The origin of the projectile hit. + * @param {number} data.radius - The radius of the splash damage. + * @param {string} data.shape - The shape of the radius. + * @param {Vector3D} [data.direction] - The unit vector defining the direction. Needed for linear splash damage. + * @param {number[]} data.playersToDamage - The array of player id's to damage. + */ +Attacking.prototype.CauseDamageOverArea = function(data) +{ + // Get nearby entities and define variables + let nearEnts = this.EntitiesNearPoint(data.origin, data.radius, data.playersToDamage); + let damageMultiplier = 1; + + // Cycle through all the nearby entities and damage it appropriately based on its distance from the origin. + for (let ent of nearEnts) + { + let entityPosition = Engine.QueryInterface(ent, IID_Position).GetPosition2D(); + if (data.shape == 'Circular') // circular effect with quadratic falloff in every direction + damageMultiplier = 1 - data.origin.distanceToSquared(entityPosition) / (data.radius * data.radius); + else if (data.shape == 'Linear') // linear effect with quadratic falloff in two directions (only used for certain missiles) + { + // Get position of entity relative to splash origin. + let relativePos = entityPosition.sub(data.origin); + + // Get the position relative to the missile direction. + let direction = Vector2D.from3D(data.direction); + let parallelPos = relativePos.dot(direction); + let perpPos = relativePos.cross(direction); + + // The width of linear splash is one fifth of the normal splash radius. + let width = data.radius / 5; + + // Check that the unit is within the distance splash width of the line starting at the missile's + // landing point which extends in the direction of the missile for length splash radius. + if (parallelPos >= 0 && Math.abs(perpPos) < width) // If in radius, quadratic falloff in both directions + damageMultiplier = (1 - parallelPos * parallelPos / (data.radius * data.radius)) * + (1 - perpPos * perpPos / (width * width)); + else + damageMultiplier = 0; + } + else // In case someone calls this function with an invalid shape. + { + warn("The " + data.shape + " splash damage shape is not implemented!"); + } + + this.HandleAttackEffects(data.type + ".Splash", data.attackData, ent, data.attacker, data.attackerOwner, damageMultiplier); + } +}; + +Attacking.prototype.HandleAttackEffects = function(attackType, attackData, target, attacker, attackerOwner, bonusMultiplier = 1) +{ + let targetState = {}; + for (let effectType of g_EffectTypes) + { + if (!attackData[effectType]) + continue; + + bonusMultiplier *= !attackData.Bonuses ? 1 : GetAttackBonus(attacker, target, attackType, attackData.Bonuses); + + let receiver = g_EffectReceiver[effectType]; + let cmpReceiver = Engine.QueryInterface(target, global[receiver.IID]); + if (!cmpReceiver) + return; + + Object.assign(targetState, cmpReceiver[receiver.method](attackData[effectType], attacker, attackerOwner, bonusMultiplier)); + } + + let cmpPromotion = Engine.QueryInterface(attacker, IID_Promotion); + if (cmpPromotion && targetState.xp) + cmpPromotion.IncreaseXp(targetState.xp); + + if (targetState.killed) + this.TargetKilled(attacker, target, attackerOwner); + + Engine.PostMessage(target, MT_Attacked, { + "type": attackType, + "target": target, + "attacker": attacker, + "attackerOwner": attackerOwner, + "damage": -(targetState.HPchange || 0), + "capture": targetState.captureChange || 0, + "statusEffects": targetState.inflictedStatuses || [], + }); +}; + +/** + * Gets entities near a give point for given players. + * @param {Vector2D} origin - The point to check around. + * @param {number} radius - The radius around the point to check. + * @param {number[]} players - The players of which we need to check entities. + * @return {number[]} The id's of the entities in range of the given point. + */ +Attacking.prototype.EntitiesNearPoint = function(origin, radius, players) +{ + // If there is insufficient data return an empty array. + if (!origin || !radius || !players) + return []; + + return Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).ExecuteQueryAroundPos(origin, 0, radius, players, IID_Resistance); +}; + +/** + * Called when a unit kills something (another unit, building, animal etc). + * @param {number} attacker - The entity id of the killer. + * @param {number} target - The entity id of the target. + * @param {number} attackerOwner - The player id of the attacker. + */ +Attacking.prototype.TargetKilled = function(attacker, target, attackerOwner) +{ + let cmpAttackerOwnership = Engine.QueryInterface(attacker, IID_Ownership); + let atkOwner = cmpAttackerOwnership && cmpAttackerOwnership.GetOwner() != INVALID_PLAYER ? cmpAttackerOwnership.GetOwner() : attackerOwner; + + // Add to killer statistics. + let cmpKillerPlayerStatisticsTracker = QueryPlayerIDInterface(atkOwner, IID_StatisticsTracker); + if (cmpKillerPlayerStatisticsTracker) + cmpKillerPlayerStatisticsTracker.KilledEntity(target); + // Add to loser statistics. + let cmpTargetPlayerStatisticsTracker = QueryOwnerInterface(target, IID_StatisticsTracker); + if (cmpTargetPlayerStatisticsTracker) + cmpTargetPlayerStatisticsTracker.LostEntity(target); + + // If killer can collect loot, let's try to collect it. + let cmpLooter = Engine.QueryInterface(attacker, IID_Looter); + if (cmpLooter) + cmpLooter.Collect(target); +}; + +var AttackingInstance = new Attacking(); +Engine.RegisterGlobal("Attacking", AttackingInstance); Property changes on: ps/trunk/binaries/data/mods/public/simulation/helpers/Attacking.js ___________________________________________________________________ Added: svn:eol-style ## -0,0 +1 ## +native \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_cavalry.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_cavalry.xml (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_cavalry.xml (revision 22754) @@ -1,106 +1,106 @@ 3 1 15 - 2 + 2 4 1000 Field Palisade SiegeWall StoneWall 100.0 0.0 0.0 2 1 15 100 7.5 160 CitizenSoldier Human Organic Cavalry Citizen Soldier Cavalry Basic special/formations/wedge 130 10 5 0 0 pitch 150 2.0 1.0 5 20 20 20 20 circle/128x128.png circle/128x128_mask.png voice/{lang}/civ/civ_{phenotype}_walk.xml voice/{lang}/civ/civ_{phenotype}_attack.xml voice/{lang}/civ/civ_{phenotype}_gather.xml voice/{lang}/civ/civ_{phenotype}_garrison.xml actor/mounted/movement/walk.xml actor/mounted/movement/walk.xml attack/impact/arrow_metal.xml attack/weapon/sword.xml attack/weapon/arrowfly.xml actor/fauna/death/death_horse.xml interface/alarm/alarm_create_cav.xml 6.5 1.95 92 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_hero.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_hero.xml (revision 22753) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_hero.xml (revision 22754) @@ -1,79 +1,79 @@ 10.0 20.0 15.0 - 15 + 15 4 1000 Field Palisade SiegeWall StoneWall units/heroes/hero_garrison 2 40 100 250 600 Hero Organic Human Hero Soldier phase_city 400 10 0 0 25 hero star/256x256.png star/256x256_mask.png interface/alarm/alarm_create_infantry.xml voice/{lang}/civ/civ_{phenotype}_heal.xml voice/{lang}/civ/civ_{phenotype}_walk.xml voice/{lang}/civ/civ_{phenotype}_attack.xml voice/{lang}/civ/civ_{phenotype}_gather.xml voice/{lang}/civ/civ_{phenotype}_repair.xml voice/{lang}/civ/civ_{phenotype}_garrison.xml attack/weapon/sword.xml attack/impact/arrow_metal.xml attack/weapon/arrowfly.xml actor/human/movement/walk.xml actor/human/movement/walk.xml actor/human/death/{phenotype}_death.xml Hero 88