Index: binaries/data/mods/public/globalscripts/Templates.js =================================================================== --- binaries/data/mods/public/globalscripts/Templates.js +++ binaries/data/mods/public/globalscripts/Templates.js @@ -222,6 +222,17 @@ ret.deathDamage.damage[damageType] = getEntityValue("DeathDamage/Damage/" + damageType); } + if (template.ProximityDamage) + { + ret.proximityDamage = { + "friendlyFire": template.ProximityDamage.FriendlyFire != "false", + "range": template.ProximityDamage.MaxRange, + "damage": {} + }; + for (let damageType in template.ProximityDamage.Damage) + ret.proximityDamage.damage[damageType] = getEntityValue("ProximityDamage/Damage/" + damageType); + } + if (template.Auras && auraTemplates) { ret.auras = {}; Index: binaries/data/mods/public/gui/common/tooltips.js =================================================================== --- binaries/data/mods/public/gui/common/tooltips.js +++ binaries/data/mods/public/gui/common/tooltips.js @@ -331,6 +331,33 @@ return tooltips.join("\n"); } +function getProximityDamageTooltip(template) +{ + if (!template.proximityDamage) + return ""; + + let tooltips = []; + + let proximityDamageTooltip = sprintf(g_RangeTooltipString["non-relative"][template.proximityDamage.minRange ? "minRange" : "no-minRange"], { + "attackLabel": headerFont("Proximity Damage"), + "damageTypes": damageTypesToText(template.proximityDamage.strengths), + "rangeLabel": headerFont(translate("Range:")), + "minRange": template.proximityDamage.minRange, + "maxRange": template.proximityDamage.maxRange, + "rangeUnit": unitFont(template.proximityDamage.minRange || translatePlural("meter", "meters", template.proximityDamage.maxRange)), + "rate": getSecondsString(template.proximityDamage.rate / 1000) + }); + + if (g_AlwaysDisplayFriendlyFire || template.proximityDamage.friendlyFire) + proximityDamageTooltip += commaFont(translate(", ")) + sprintf(translate("Friendly Fire: %(enabled)s"), { + "enabled": template.proximityDamage.friendlyFire ? translate("Yes") : translate("No") + }); + + tooltips.push(proximityDamageTooltip); + + return tooltips.join("\n"); +} + function getGarrisonTooltip(template) { if (!template.garrisonHolder) Index: binaries/data/mods/public/gui/session/selection_details.js =================================================================== --- binaries/data/mods/public/gui/session/selection_details.js +++ binaries/data/mods/public/gui/session/selection_details.js @@ -286,6 +286,7 @@ Engine.GetGUIObjectByName("attackAndArmorStats").tooltip = [ getAttackTooltip, getSplashDamageTooltip, + getProximityDamageTooltip, getHealerTooltip, getArmorTooltip, getGatherTooltip, Index: binaries/data/mods/public/simulation/components/Damage.js =================================================================== --- binaries/data/mods/public/simulation/components/Damage.js +++ binaries/data/mods/public/simulation/components/Damage.js @@ -238,6 +238,70 @@ }; /** + * Damages units around a given entity. + * @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 attacker. + * @param {number} data.minRange - The minimal radius of the proximity damage. + * @param {number} data.maxRange - The maximal radius of the proximity 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 {Object} data.bonus - The attack bonus template from the attacker. + * @param {number[]} data.playersToDamage - The array of player id's to damage. + * @param {string[]} data.restrictedClasses - An array of classes not to damage. + * @param {Object} data.statusEffects - Status effects e.g. poisoning, burning etc. + */ +Damage.prototype.CauseProximityDamage = function(data) +{ + // Get nearby entities. + let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); + let nearEnts = cmpRangeManager.ExecuteQuery(data.attacker, data.minRange, data.maxRange, data.playersToDamage, IID_DamageReceiver); + let damageMultiplier = 1; + + // Cycle through all the nearby entities and damage them appropriately based on its distance from the origin. + for (let ent of nearEnts) + { + // Do not damage restricted classes. + let cmpIdentity = QueryMiragedInterface(ent, IID_Identity); + let targetClasses = cmpIdentity.GetClassesList(); + if (data.restrictedClasses.length && MatchesClassList(targetClasses, data.restrictedClasses)) + continue; + + // Calculate distance effects. + let cmpPosition = Engine.QueryInterface(ent, IID_Position); + if (!cmpPosition || !cmpPosition.IsInWorld()) + continue; + let entityPosition = cmpPosition.GetPosition2D(); + if (data.shape == 'Circular') + damageMultiplier = 1 - data.origin.distanceToSquared(entityPosition) / Math.square(data.maxRange - data.minRange); + else + warn("The " + data.shape + " proximity damage shape is not implemented!"); + + if (data.bonus) + damageMultiplier *= GetDamageBonus(ent, data.bonus); + + if (data.statusEffects) + { + let cmpStatusReceiver = Engine.QueryInterface(ent, IID_StatusEffectsReceiver); + if (cmpStatusReceiver) + cmpStatusReceiver.InflictEffects(data.statusEffects); + } + + // Call CauseDamage which reduces the hitpoints, posts network command, plays sounds etc. + this.CauseDamage({ + "strengths": data.strengths, + "target": ent, + "attacker": data.attacker, + "multiplier": damageMultiplier, + "type": data.type, + "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 }. Index: binaries/data/mods/public/simulation/components/GuiInterface.js =================================================================== --- binaries/data/mods/public/simulation/components/GuiInterface.js +++ binaries/data/mods/public/simulation/components/GuiInterface.js @@ -436,6 +436,15 @@ if (cmpArmour) ret.armour = cmpArmour.GetArmourStrengths(); + let cmpProximityDamage = Engine.QueryInterface(ent, IID_ProximityDamage); + if (cmpProximityDamage) + ret.proximityDamage = { + "strengths": cmpProximityDamage.GetProximityDamageStrengths(), + "minRange": cmpProximityDamage.GetRange().min, + "maxRange": cmpProximityDamage.GetRange().max, + "rate": cmpProximityDamage.GetTimers().repeat + }; + let cmpBuildingAI = Engine.QueryInterface(ent, IID_BuildingAI); if (cmpBuildingAI) ret.buildingAI = { Index: binaries/data/mods/public/simulation/components/ProximityDamage.js =================================================================== --- /dev/null +++ binaries/data/mods/public/simulation/components/ProximityDamage.js @@ -0,0 +1,270 @@ +function ProximityDamage() {} + +ProximityDamage.prototype.statusEffectsSchema = + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; + +ProximityDamage.prototype.bonusesSchema = + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; + +ProximityDamage.prototype.restrictedClassesSchema = + "" + + "" + + "" + + "tokens" + + "" + + "" + + "" + + ""; + +ProximityDamage.prototype.Schema = + "Whether a unit or building inflicts damage to nearby units." + + "" + + "" + + "0.0" + + "10.0" + + "50.0" + + "" + + "0" + + "10" + + "true" + + "Circular" + + "true" + + "0" + + "200" + + "1000" + + "" + + "" + + "Support" + + "2" + + "" + + "" + + "" + + "" + + DamageTypes.BuildSchema("damage strength") + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + ProximityDamage.prototype.statusEffectsSchema + + ProximityDamage.prototype.restrictedClassesSchema + + ProximityDamage.prototype.bonusesSchema; + +ProximityDamage.prototype.Init = function() +{ +}; + +/** + * Find the entities not to be damaged by this proximity damage. + * @return {Array} - Classes not to be damaged by this proximity damage. + */ +ProximityDamage.prototype.GetRestrictedClasses = function() +{ + if (this.template.RestrictedClasses && + this.template.RestrictedClasses._string) + return this.template.RestrictedClasses._string.split(/\s+/); + + return []; +}; + +ProximityDamage.prototype.Serialize = function() +{ + return { "proximityDamageTimer": this.proximityDamageTimer }; +}; + +ProximityDamage.prototype.Deserialize = function(data) +{ + this.proximityDamageTimer = data.proximityDamageTimer; +}; + +/** + * Work out the range value with technology effects. + * @return {object} - The min/max range values of the proximity damage. + */ +ProximityDamage.prototype.GetRange = function() +{ + let min = ApplyValueModificationsToEntity("ProximityDamage/MinRange", +this.template.MinRange, this.entity); + let max = ApplyValueModificationsToEntity("ProximityDamage/MaxRange", +this.template.MaxRange, this.entity); + return { "max": max, "min": min } +} + +/** + * Work out the timer value with technology effects. + * @return {object} - The prepare/repeat timers of the proximity damage. + */ +ProximityDamage.prototype.GetTimers = function() +{ + let prepare = ApplyValueModificationsToEntity("ProximityDamage/PrepareTime", +this.template.PrepareTime, this.entity); + let repeat = ApplyValueModificationsToEntity("ProximityDamage/RepeatTime", +this.template.RepeatTime, this.entity); + return { "prepare": prepare, "repeat": repeat }; +} + +/** + * Work out the damage values with technology effects. + * @return {Array} modifications to the damage types. + */ +ProximityDamage.prototype.GetProximityDamageStrengths = function() +{ + let applyMods = damageType => + ApplyValueModificationsToEntity("ProximityDamage/Damage/" + damageType, +(this.template.Damage[damageType] || 0), this.entity); + + let ret = {}; + for (let damageType of DamageTypes.GetTypes()) + ret[damageType] = applyMods(damageType); + + return ret; +}; + +/** + * Handle any bonuses with this damage type. + * @return {Array} - Bonuses to apply. + */ +ProximityDamage.prototype.GetBonusTemplate = function() +{ + return this.template.Bonuses || null; +}; + +ProximityDamage.prototype.CauseProximityDamage = function() +{ + // Return when this entity is otherworldly or garrisoned + let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); + if (!cmpPosition || !cmpPosition.IsInWorld()) + return; + + let owner; + let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); + if (cmpOwnership) + owner = cmpOwnership.GetOwner(); + if (owner == INVALID_PLAYER) + warn("Unit causing proximity damage does not have any owner."); + + // Get an array of the players which ought to be damaged. + let playersToDamage; + let cmpDamage = Engine.QueryInterface(SYSTEM_ENTITY, IID_Damage); + if (cmpDamage) + playersToDamage = cmpDamage.GetPlayersToDamage(owner, this.template.FriendlyFire != "false"); + + let radii = this.GetRange(); + + // Call the Damage component to find out which entities to damage and damage them. + if (cmpDamage) + cmpDamage.CauseProximityDamage({ + "attacker": this.entity, + "origin": cmpPosition.GetPosition2D(), + "minRange": radii.min, + "maxRange": radii.max, + "shape": this.template.Shape, + "strengths": this.GetProximityDamageStrengths(), + "bonus": this.GetBonusTemplate(), + "playersToDamage": playersToDamage, + "restrictedClasses": this.GetRestrictedClasses(), + "type": "Proximity", + "attackerOwner": owner, + "statusEffects": this.template.StatusEffects + }); +}; + +// Start the proximity damage timer when no timer exists. +ProximityDamage.prototype.CheckTimer = function() +{ + // If the unit only causes proximity damage whilst moving, disable timer when not moving. + if (this.template.OnlyWhenMoving == "true") + { + let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); + if (!cmpUnitMotion || !cmpUnitMotion.IsMoving()) + { + // We don't need a timer, disable if one exists + if (this.proximityDamageTimer) + { + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + cmpTimer.CancelTimer(this.proximityDamageTimer); + delete this.proximityDamageTimer; + } + return; + } + } + + // We need a timer, enable if one doesn't exist. + if (this.proximityDamageTimer) + return; + + let timers = this.GetTimers(); + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + this.proximityDamageTimer = cmpTimer.SetInterval(this.entity, IID_ProximityDamage, "CauseProximityDamage", timers.prepare, timers.repeat, undefined); +}; + +/** + * Check timer when a unit changes motion. + * @param {{ "msg.starting": boolean, "msg.error": boolean }} - Whether a move is started and whether an error is thrown. + */ +ProximityDamage.prototype.OnMotionChanged = function(msg) +{ + this.CheckTimer(); +}; + +/** + * Check timer when a unit changes ownership. + * @param {{ "msg.from": number, "msg.to": number }} - From which player to which player the ownership is changed. + */ +ProximityDamage.prototype.OnOwnershipChanged = function(msg) +{ + if (msg.to == INVALID_PLAYER) + { + if (this.proximityDamageTimer) + { + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + cmpTimer.CancelTimer(this.proximityDamageTimer); + delete this.proximityDamageTimer; + } + return; + } + + this.CheckTimer(); +}; + +Engine.RegisterComponentType(IID_ProximityDamage, "ProximityDamage", ProximityDamage); Index: binaries/data/mods/public/simulation/components/interfaces/ProximityDamage.js =================================================================== --- /dev/null +++ binaries/data/mods/public/simulation/components/interfaces/ProximityDamage.js @@ -0,0 +1 @@ +Engine.RegisterInterface("ProximityDamage"); Index: binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js =================================================================== --- binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js +++ binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js @@ -8,6 +8,7 @@ Engine.LoadComponentScript("interfaces/CeasefireManager.js"); Engine.LoadComponentScript("interfaces/DamageReceiver.js"); Engine.LoadComponentScript("interfaces/DeathDamage.js"); +Engine.LoadComponentScript("interfaces/ProximityDamage.js"); Engine.LoadComponentScript("interfaces/EndGameManager.js"); Engine.LoadComponentScript("interfaces/EntityLimits.js"); Engine.LoadComponentScript("interfaces/Foundation.js"); Index: binaries/data/mods/public/simulation/components/tests/test_ProximityDamage.js =================================================================== --- /dev/null +++ binaries/data/mods/public/simulation/components/tests/test_ProximityDamage.js @@ -0,0 +1,105 @@ +Engine.LoadHelperScript("DamageBonus.js"); +Engine.LoadHelperScript("DamageTypes.js"); +Engine.LoadHelperScript("ValueModification.js"); +Engine.LoadComponentScript("interfaces/AuraManager.js"); +Engine.LoadComponentScript("interfaces/Damage.js"); +Engine.LoadComponentScript("interfaces/ProximityDamage.js"); +Engine.LoadComponentScript("interfaces/TechnologyManager.js"); +Engine.LoadComponentScript("interfaces/Timer.js"); +Engine.LoadComponentScript("ProximityDamage.js"); +Engine.LoadComponentScript("Timer.js"); + +let cmpTimer = ConstructComponent(SYSTEM_ENTITY, "Timer"); +cmpTimer.OnUpdate({ turnLength: 1 }); + +let damagingEnt = 42; +let player = 1; +let playersToDamage = [1, 2, 3, 7]; +let pos = new Vector2D(3, 4.2); + +AddMock(damagingEnt, IID_Position, { + "GetPosition2D": () => pos, + "IsInWorld": () => true +}); + +AddMock(damagingEnt, IID_Ownership, { + "GetOwner": () => player +}); + +ApplyValueModificationsToEntity = function(value, stat, ent) +{ + if (value == "ProximityDamage/Damage/Pierce" && ent == damagingEnt) + return stat + 200; + return stat; +}; + +let cmpProximityDamage = ConstructComponent(damagingEnt, "ProximityDamage", { + "Damage": { + "Hack": 0.0, + "Pierce": 10.0, + "Crush": 50.0, + }, + "MinRange": 10, + "MaxRange": 10, + "Shape": "Circular", + "FriendlyFire": true, + "OnlyWhenMoving": false +}); + +let modifiedDamage = { + "Hack": 0.0, + "Pierce": 210.0, + "Crush": 50.0 +}; + +let result = { + "attacker": damagingEnt, + "origin": pos, + "radius": cmpProximityDamage.Range, + "shape": cmpProximityDamage.Shape, + "strengths": modifiedDamage, + "bonus": null, + "playersToDamage": playersToDamage, + "type": "Proximity", + "attackerOwner": player +}; + +TS_ASSERT(cmpProximityDamage.GetBonusTemplate() == null); + +AddMock(damagingEnt, IID_ProximityDamage, { + "CauseProximityDamage": data => TS_ASSERT_UNEVAL_EQUALS(data, result), + "GetPlayersToDamage": (owner, friendlyFire) => playersToDamage +}); + +TS_ASSERT_UNEVAL_EQUALS(cmpProximityDamage.GetProximityDamageStrengths(), modifiedDamage); +cmpProximityDamage.CauseProximityDamage(); + +// Test proximity damage with bonus + +cmpProximityDamage = ConstructComponent(6, "ProximityDamage", { + "Shape": "Circular", + "Range": 10, + "FriendlyFire": true, + "OnlyWhenMoving": false, + "Damage": { + "Hack": 0.0, + "Pierce": 10.0, + "Crush": 50.0, + }, + "RestrictedClasses":{ + "_string": "Javelin" + }, + "Bonuses": { + "Bonus1":{ + "Civ": "iber" + }, + "Bonus2":{ + "Classes": "Javelin" + }, + "Bonus3":{ + "Civ": "athen", + "Multiplier": 2 + } + } +}); +TS_ASSERT(cmpProximityDamage.GetBonusTemplate() != null); Index: binaries/data/mods/public/simulation/templates/template_unit_cavalry.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_unit_cavalry.xml +++ binaries/data/mods/public/simulation/templates/template_unit_cavalry.xml @@ -21,6 +21,22 @@ 2 + + + 0.0 + 0.0 + 10.0 + + 0 + 10 + true + Circular + true + 0 + 100 + 0 + Structure Cavalry Ship Elephant + 1 15