Index: binaries/data/mods/public/globalscripts/Templates.js =================================================================== --- binaries/data/mods/public/globalscripts/Templates.js +++ binaries/data/mods/public/globalscripts/Templates.js @@ -386,6 +386,17 @@ "time": getEntityValue("Pack/Time"), }; + if (template.ProximityAttack) + { + ret.proximityAttack = { + "friendlyFire": template.ProximityAttack.FriendlyFire != "false", + "minRange": template.ProximityAttack.MinRange, + "maxRange": template.ProximityAttack.MaxRange, + "rate": template.ProximityAttack.RepeatTime + }; + Object.assign(ret.proximityAttack, getAttackEffects(template.ProximityAttack, "ProximityAttack")); + } + if (template.Health) ret.health = Math.round(getEntityValue("Health/Max")); 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 @@ -328,6 +328,39 @@ return tooltips.join("\n"); } +function getProximityAttackTooltip(template) +{ +// Can very well be improved (after D2138). + if (!template.proximityAttack) + return ""; + + let proximityAttackTooltip; + if (template.proximityAttack.minRange > 0) + proximityAttackTooltip = sprintf(translate("%(attackLabel)s %(damageTypes)s, %(rangeLabel)s %(minRange)s to %(maxRange)s %(rangeUnit)s"), { + "attackLabel": headerFont("Proximity Attack:"), + "damageTypes": damageTypesToText(template.proximityAttack.strengths.Damage), + "rangeLabel": headerFont(translate("Range:")), + "minRange": template.proximityAttack.minRange, + "maxRange": template.proximityAttack.maxRange, + "rangeUnit": unitFont(translatePlural("meter", "meters", +template.proximityAttack.maxRange)) + }); + else + proximityAttackTooltip = sprintf(translate("%(attackLabel)s %(damageTypes)s, %(rangeLabel)s %(maxRange)s %(rangeUnit)s"), { + "attackLabel": headerFont("Proximity Attack:"), + "damageTypes": damageTypesToText(template.proximityAttack.strengths.Damage), + "rangeLabel": headerFont(translate("Radius:")), + "maxRange": template.proximityAttack.maxRange, + "rangeUnit": unitFont(translatePlural("meter", "meters", +template.proximityAttack.maxRange)) + }); + + if (g_AlwaysDisplayFriendlyFire || template.proximityAttack.friendlyFire) + proximityAttackTooltip += commaFont(translate(", ")) + sprintf(translate("Friendly Fire: %(enabled)s"), { + "enabled": template.proximityAttack.friendlyFire ? translate("Yes") : translate("No") + }); + + return proximityAttackTooltip; +} + function getGarrisonTooltip(template) { if (!template.garrisonHolder) Index: binaries/data/mods/public/gui/reference/common/draw.js =================================================================== --- binaries/data/mods/public/gui/reference/common/draw.js +++ binaries/data/mods/public/gui/reference/common/draw.js @@ -14,6 +14,7 @@ getHealerTooltip, getAttackTooltip, getSplashDamageTooltip, + getProximityAttackTooltip, getArmorTooltip, getGarrisonTooltip, getProjectilesTooltip, 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, + getProximityAttackTooltip, getHealerTooltip, getArmorTooltip, getGatherTooltip, Index: binaries/data/mods/public/maps/random/polar_sea_triggers.js =================================================================== --- binaries/data/mods/public/maps/random/polar_sea_triggers.js +++ binaries/data/mods/public/maps/random/polar_sea_triggers.js @@ -51,7 +51,7 @@ continue; // The returned entities are sorted by RangeManager already - let targets = Attack.EntitiesNearPoint(attackerPos, 200, players).filter(ent => { + let targets = Attack.EntitiesNearPoint(attackerPos, 0, 200, players).filter(ent => { let cmpIdentity = Engine.QueryInterface(ent, IID_Identity); return cmpIdentity && MatchesClassList(cmpIdentity.GetClassesList(), targetClasses); }); Index: binaries/data/mods/public/simulation/components/DeathDamage.js =================================================================== --- binaries/data/mods/public/simulation/components/DeathDamage.js +++ binaries/data/mods/public/simulation/components/DeathDamage.js @@ -68,7 +68,8 @@ "attacker": this.entity, "attackerOwner": owner, "origin": pos, - "radius": radius, + "minRange": 0, + "maxRange": radius, "shape": this.template.Shape, "playersToDamage": playersToDamage, }); Index: binaries/data/mods/public/simulation/components/DelayedDamage.js =================================================================== --- binaries/data/mods/public/simulation/components/DelayedDamage.js +++ binaries/data/mods/public/simulation/components/DelayedDamage.js @@ -44,7 +44,8 @@ "attacker": data.attacker, "attackerOwner": data.attackerOwner, "origin": Vector2D.from3D(data.position), - "radius": data.splash.radius, + "minRange": 0, + "maxRange": data.splash.radius, "shape": data.splash.shape, "direction": data.direction, "playersToDamage": Attacking.GetPlayersToDamage(data.attackerOwner, data.splash.friendlyFire) @@ -69,7 +70,7 @@ // 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()); + let ents = Attacking.EntitiesNearPoint(Vector2D.from3D(data.position), 0, targetPosition.horizDistanceTo(data.position) * 2, cmpPlayer.GetEnemies()); for (let ent of ents) { if (!Attacking.TestCollision(ent, data.position, lateness)) 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 @@ -432,6 +432,16 @@ } } + let cmpProximityAttack = Engine.QueryInterface(ent, IID_ProximityAttack); + if (cmpProximityAttack) + ret.proximityAttack = { + "friendlyFire": cmpProximityAttack.GetFriendlyFire() != "false", + "strengths": cmpProximityAttack.GetAttackEffectsData(), + "minRange": cmpProximityAttack.GetRange().min, + "maxRange": cmpProximityAttack.GetRange().max, + "rate": cmpProximityAttack.GetTimers().repeat + }; + let cmpArmour = Engine.QueryInterface(ent, IID_Resistance); if (cmpArmour) ret.armour = cmpArmour.GetArmourStrengths("Damage"); Index: binaries/data/mods/public/simulation/components/ProximityAttack.js =================================================================== --- /dev/null +++ binaries/data/mods/public/simulation/components/ProximityAttack.js @@ -0,0 +1,194 @@ +function ProximityAttack() {} + +ProximityAttack.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" + + "" + + "" + + "" + + Attacking.BuildAttackEffectsSchema() + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; + +ProximityAttack.prototype.Init = function() +{ +}; + +ProximityAttack.prototype.Serialize = function() +{ + return { "ProximityAttackTimer": this.ProximityAttackTimer }; +}; + +ProximityAttack.prototype.Deserialize = function(data) +{ + this.ProximityAttackTimer = data.ProximityAttackTimer; +}; + +/** + * Work out the range value with technology effects. + * @return {object} - The min/max range values of the proximity damage. + */ +ProximityAttack.prototype.GetRange = function() +{ + return { + "min": ApplyValueModificationsToEntity("ProximityAttack/MinRange", +(this.template.MinRange || 0), this.entity), + "max": ApplyValueModificationsToEntity("ProximityAttack/MaxRange", +this.template.MaxRange, this.entity) + }; +}; + +/** + * Work out the timer value with technology effects. + * @return {object} - The prepare/repeat timers of the proximity damage. + */ +ProximityAttack.prototype.GetTimers = function() +{ + return { + "prepare": ApplyValueModificationsToEntity("ProximityAttack/PrepareTime", +this.template.PrepareTime, this.entity), + "repeat": ApplyValueModificationsToEntity("ProximityAttack/RepeatTime", +this.template.RepeatTime, this.entity) + }; +}; + +ProximityAttack.prototype.GetAttackEffectsData = function() +{ + return Attacking.GetAttackEffectsData("ProximityAttack/", this.template, this.entity); +}; + +/** + * Returns whether friendly fire is enabled for this proximity attack. + * This function is needed for the GUI. + * @return {boolean} - Whether friendly fire is enabled for this proximity attack. + */ +ProximityAttack.prototype.GetFriendlyFire = function() +{ + return this.template.FriendlyFire; +}; + +ProximityAttack.prototype.CauseProximityAttack = 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 = Attacking.GetPlayersToDamage(owner, this.template.FriendlyFire != "false"); + + let radii = this.GetRange(); + + // Call the Damage component to find out which entities to damage and damage them. + Attacking.CauseDamageOverArea({ + "type": "Proximity", + "attackData": this.GetAttackEffectsData(), + "origin": cmpPosition.GetPosition2D(), + "attacker": this.entity, + "attackerOwner": owner, + "minRange": radii.min, + "maxRange": radii.max, + "shape": this.template.Shape, + "playersToDamage": playersToDamage + }); +}; + +// Start the proximity damage timer when no timer exists. +ProximityAttack.prototype.CheckTimer = function() +{ + // If the unit only causes proximity damage whilst moving, disable timer when not moving. + if (this.template.OnlyWhenMoving != "false") + { + let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); + if (!cmpPosition || cmpPosition.IsInWorld() && !Vector3D.isEqualTo(cmpPosition.GetPreviousPosition(), cmpPosition.GetPosition())) + { + // We don't need a timer, disable if one exists. + if (this.ProximityAttackTimer) + { + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + cmpTimer.CancelTimer(this.ProximityAttackTimer); + delete this.ProximityAttackTimer; + } + return; + } + } + // We need a timer, enable if one doesn't exist. + if (this.ProximityAttackTimer) + return; + + let timers = this.GetTimers(); + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + this.ProximityAttackTimer = cmpTimer.SetInterval(this.entity, IID_ProximityAttack, "CauseProximityAttack", timers.prepare, timers.repeat, undefined); +}; + +/** + * Check timer when a unit changes motion. + * @param {{ "msg.to": string }} - The state to which the unit has changed. + */ +ProximityAttack.prototype.OnUnitAIStateChanged = 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. + */ +ProximityAttack.prototype.OnOwnershipChanged = function(msg) +{ + if (msg.to == INVALID_PLAYER) + { + if (this.ProximityAttackTimer) + { + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + cmpTimer.CancelTimer(this.ProximityAttackTimer); + delete this.ProximityAttackTimer; + } + return; + } + + this.CheckTimer(); +}; + +Engine.RegisterComponentType(IID_ProximityAttack, "ProximityAttack", ProximityAttack); Index: binaries/data/mods/public/simulation/components/interfaces/ProximityAttack.js =================================================================== --- /dev/null +++ binaries/data/mods/public/simulation/components/interfaces/ProximityAttack.js @@ -0,0 +1 @@ +Engine.RegisterInterface("ProximityAttack"); Index: binaries/data/mods/public/simulation/components/tests/test_Damage.js =================================================================== --- binaries/data/mods/public/simulation/components/tests/test_Damage.js +++ binaries/data/mods/public/simulation/components/tests/test_Damage.js @@ -166,7 +166,8 @@ "attacker": attacker, "attackerOwner": attackerOwner, "origin": origin, - "radius": 10, + "minRange": 0, + "maxRange": 10, "shape": "Linear", "direction": new Vector3D(1, 747, 0), "playersToDamage": [2], @@ -174,7 +175,7 @@ let fallOff = function(x, y) { - return (1 - x * x / (data.radius * data.radius)) * (1 - 25 * y * y / (data.radius * data.radius)); + return (1 - x * x / (data.maxRange * data.maxRange)) * (1 - 25 * y * y / (data.maxRange * data.maxRange)); }; let hitEnts = new Set(); @@ -185,14 +186,17 @@ AddMock(60, IID_Position, { "GetPosition2D": () => new Vector2D(2.2, -0.4), + "IsInWorld": () => true }); AddMock(61, IID_Position, { "GetPosition2D": () => new Vector2D(0, 0), + "IsInWorld": () => true }); AddMock(62, IID_Position, { "GetPosition2D": () => new Vector2D(5, 2), + "IsInWorld": () => true }); AddMock(60, IID_Health, { @@ -262,23 +266,28 @@ AddMock(60, IID_Position, { "GetPosition2D": () => new Vector2D(3, 4), + "IsInWorld": () => true }); AddMock(61, IID_Position, { "GetPosition2D": () => new Vector2D(0, 0), + "IsInWorld": () => true }); AddMock(62, IID_Position, { "GetPosition2D": () => new Vector2D(3.6, 3.2), + "IsInWorld": () => true }); AddMock(63, IID_Position, { "GetPosition2D": () => new Vector2D(10, -10), + "IsInWorld": () => true }); // Target on the frontier of the shape AddMock(64, IID_Position, { "GetPosition2D": () => new Vector2D(9, -4), + "IsInWorld": () => true }); AddMock(60, IID_Resistance, { @@ -321,7 +330,8 @@ "attacker": 50, "attackerOwner": 1, "origin": new Vector2D(3, 4), - "radius": radius, + "minRange": 0, + "maxRange": radius, "shape": "Circular", "playersToDamage": [2], }); Index: binaries/data/mods/public/simulation/components/tests/test_DeathDamage.js =================================================================== --- binaries/data/mods/public/simulation/components/tests/test_DeathDamage.js +++ binaries/data/mods/public/simulation/components/tests/test_DeathDamage.js @@ -45,7 +45,8 @@ "attacker": deadEnt, "attackerOwner": player, "origin": pos, - "radius": template.Range, + "minRange": 0, + "maxRange": template.Range, "shape": template.Shape, "playersToDamage": playersToDamage }; 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 @@ -22,6 +22,7 @@ Engine.LoadComponentScript("interfaces/Pack.js"); Engine.LoadComponentScript("interfaces/ProductionQueue.js"); Engine.LoadComponentScript("interfaces/Promotion.js"); +Engine.LoadComponentScript("interfaces/ProximityAttack.js"); Engine.LoadComponentScript("interfaces/Repairable.js"); Engine.LoadComponentScript("interfaces/ResourceDropsite.js"); Engine.LoadComponentScript("interfaces/ResourceGatherer.js"); Index: binaries/data/mods/public/simulation/components/tests/test_ProximityAttack.js =================================================================== --- /dev/null +++ binaries/data/mods/public/simulation/components/tests/test_ProximityAttack.js @@ -0,0 +1,106 @@ +Engine.LoadHelperScript("DamageBonus.js"); +Engine.LoadHelperScript("Attacking.js"); +Engine.LoadHelperScript("ValueModification.js"); +Engine.LoadComponentScript("interfaces/ProximityAttack.js"); +Engine.LoadComponentScript("interfaces/TechnologyManager.js"); +Engine.LoadComponentScript("interfaces/Timer.js"); +Engine.LoadComponentScript("ProximityAttack.js"); +Engine.LoadComponentScript("Timer.js"); + +let cmpTimer = ConstructComponent(SYSTEM_ENTITY, "Timer"); +cmpTimer.OnUpdate({ "turnLength": 100 }); + +let damagingEnt = 42; +let player = 1; +let playersToDamage = [1, 2, 3, 7]; +let pos = new Vector2D(3, 4.2); +let pos3D = new Vector3D(3, 0, 4.2); + +ApplyValueModificationsToEntity = function(value, stat, ent) +{ + return stat; +}; + +let template = { + "Damage": { + "Pierce": 10.0, + "Crush": 50.0, + }, + "MinRange": 4, + "MaxRange": 9, + "PrepareTime": 0, + "RepeatTime": 100, + "Shape": "Circular", + "FriendlyFire": true, + "OnlyWhenMoving": false +}; + +let effects = { + "Damage": { + "Pierce": 10.0, + "Crush": 50.0, + }, +}; + +let cmpProximityAttack = ConstructComponent(damagingEnt, "ProximityAttack", template); + +AddMock(damagingEnt, IID_Position, { + "GetPosition": () => pos3D, + "GetPreviousPosition": () => pos3D, + "GetPosition2D": () => pos, + "IsInWorld": () => true +}); + +AddMock(damagingEnt, IID_Ownership, { + "GetOwner": () => player +}); + +let result = { + "type": "Proximity", + "attackData": effects, + "origin": pos, + "attacker": damagingEnt, + "attackerOwner": player, + "minRange": template.MinRange, + "maxRange": template.MaxRange, + "shape": template.Shape, + "playersToDamage": playersToDamage +}; + +Attacking.CauseDamageOverArea = data => TS_ASSERT_UNEVAL_EQUALS(data, result); +Attacking.GetPlayersToDamage = (owner, friendlyFire) => playersToDamage; + +TS_ASSERT_UNEVAL_EQUALS(cmpProximityAttack.GetAttackEffectsData().Damage, template.Damage); +cmpProximityAttack.CauseProximityAttack(); + +// Test timer. +/* +// Timer needed because we do not need to move to cause damage. +cmpProximityAttack.CheckTimer(); +TS_ASSERT(cmpProximityAttack.ProximityAttackTimer != null); + +// Timer not needed because we need to move to cause damage. +template.OnlyWhenMoving = true; +cmpProximityAttack = ConstructComponent(damagingEnt, "ProximityAttack", template); +cmpProximityAttack.CheckTimer(); +TS_ASSERT(cmpProximityAttack.ProximityAttackTimer == null); + +// Timer needed because we need to move to cause damage. +let newPos3D = new Vector3D(3, 0, 4.3); +AddMock(damagingEnt, IID_Position, { + "GetPosition": () => pos3D, + "GetPreviousPosition": () => newPos3D, + "IsInWorld": () => true +}); +cmpProximityAttack.CheckTimer(); +TS_ASSERT(cmpProximityAttack.ProximityAttackTimer != null); + +// Timer not needed because we stopped moving. +AddMock(damagingEnt, IID_Position, { + "GetPosition": () => pos3D, + "GetPreviousPosition": () => pos3D, + "IsInWorld": () => true +}); +cmpProximityAttack.CheckTimer(); +TS_ASSERT(cmpProximityAttack.ProximityAttackTimer == null); +*/ Index: binaries/data/mods/public/simulation/helpers/Attacking.js =================================================================== --- binaries/data/mods/public/simulation/helpers/Attacking.js +++ binaries/data/mods/public/simulation/helpers/Attacking.js @@ -179,7 +179,8 @@ * @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 {number} data.minRange - The minimal radius of the area damage. + * @param {number} data.maxRange - The maximal radius of the area 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. @@ -187,16 +188,29 @@ Attacking.prototype.CauseDamageOverArea = function(data) { // Get nearby entities and define variables - let nearEnts = this.EntitiesNearPoint(data.origin, data.radius, data.playersToDamage); + let nearEnts = this.EntitiesNearPoint(data.origin, data.minRange, data.maxRange, data.playersToDamage); let damageMultiplier = 1; + // Radius used for calculations. + let radius = data.maxRange; + // 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) + // Do not damage self when causing proximity damage. + if (ent == data.attacker && data.type == "Proximity") + continue; + + let cmpPosition = Engine.QueryInterface(ent, IID_Position); + if (!cmpPosition || !cmpPosition.IsInWorld()) + continue; + let entityPosition = cmpPosition.GetPosition2D(); + + // Circular effect with quadratic falloff in every direction. + if (data.shape == 'Circular') + damageMultiplier = 1 - data.origin.distanceToSquared(entityPosition) / (radius * radius); + // Linear effect with quadratic falloff in two directions (only used for certain missiles). + else if (data.shape == 'Linear' && data.direction) { // Get position of entity relative to splash origin. let relativePos = entityPosition.sub(data.origin); @@ -207,17 +221,17 @@ let perpPos = relativePos.cross(direction); // The width of linear splash is one fifth of the normal splash radius. - let width = data.radius / 5; + let width = 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)) * + damageMultiplier = (1 - parallelPos * parallelPos / (radius * radius)) * (1 - perpPos * perpPos / (width * width)); else damageMultiplier = 0; } - else // In case someone calls this function with an invalid shape. + else { warn("The " + data.shape + " splash damage shape is not implemented!"); } @@ -265,17 +279,18 @@ /** * 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} minRange - The maximum distance the entities may have from the point to check. + * @param {number} maxRange - The minumum distance the entities may have from 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) +Attacking.prototype.EntitiesNearPoint = function(origin, minRange, maxRange, players) { // If there is insufficient data return an empty array. - if (!origin || !radius || !players) + if (!origin || !maxRange || !players) return []; - return Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).ExecuteQueryAroundPos(origin, 0, radius, players, IID_Resistance); + return Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).ExecuteQueryAroundPos(origin, minRange || 0, maxRange, players, IID_Resistance); }; /** 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 @@ -57,6 +57,25 @@ 150 + + + 10.0 + + 0 + 10 + true + Circular + true + 0 + 100 + 0 + + + Cavalry + 0 + + + 2.0 1.0