Index: binaries/data/mods/public/gui/session/input.js =================================================================== --- binaries/data/mods/public/gui/session/input.js +++ binaries/data/mods/public/gui/session/input.js @@ -17,6 +17,7 @@ const ACTION_REPAIR = 2; const ACTION_GUARD = 3; const ACTION_PATROL = 4; +const ACTION_ATTACKGROUND = 5; var preSelectedAction = ACTION_NONE; const INPUT_NORMAL = 0; Index: binaries/data/mods/public/gui/session/unit_actions.js =================================================================== --- binaries/data/mods/public/gui/session/unit_actions.js +++ binaries/data/mods/public/gui/session/unit_actions.js @@ -238,6 +238,44 @@ "specificness": 10, }, + "attack-ground": { + "execute": function(target, action, selection, queued) + { + Engine.PostNetworkCommand({ + "type": "attack-ground", + "entities": selection, + "target": target, + "queued": queued + }); + + Engine.GuiInterfaceCall("PlaySound", { + "name": "order_attack", + "entity": selection[0] + }); + DrawTargetMarker(target); + + return true; + }, + "getActionInfo": function(selection, target) + { + return { + "possible": true + }; + }, + "preSelectedActionCheck": function(target, selection) + { + if (preSelectedAction != ACTION_ATTACKGROUND) + return false; + + return { + "type": "attack-ground", + "cursor": "action-attack", + "target": target + }; + }, + "specificness": 50, + }, + "patrol": { "execute": function(target, action, selection, queued) @@ -1365,6 +1403,26 @@ }, }, + "attack-ground": { + "getInfo": function(entStates) + { + if (entStates.every(entState => !entState.attack || + Object.keys(entState.attack).every(type => !entState.attack[type].attackGround))) + return false; + + return { + "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.attackground") + + translate("Attack the selected ground."), + "icon": "attack_ground.png" + }; + }, + "execute": function(entStates) + { + inputState = INPUT_PRESELECTEDACTION; + preSelectedAction = ACTION_ATTACKGROUND; + }, + }, + "patrol": { "getInfo": function(entStates) { @@ -1617,7 +1675,7 @@ } return { - "possible": ["move", "attack-move", "remove-guard", "patrol"].indexOf(action) != -1 + "possible": ["move", "attack-move", "remove-guard", "patrol", "attack-ground"].indexOf(action) != -1 }; } Index: binaries/data/mods/public/simulation/components/Attack.js =================================================================== --- binaries/data/mods/public/simulation/components/Attack.js +++ binaries/data/mods/public/simulation/components/Attack.js @@ -84,6 +84,7 @@ "0.0" + "" + "" + + "" + "" + "" + "" + @@ -177,6 +178,9 @@ "" + "" + "" + + ""+ + "" + + "" + Attack.prototype.preferredClassesSchema + Attack.prototype.restrictedClassesSchema + "" + @@ -241,8 +245,25 @@ return []; }; +/** + * Whether the entity is able to attack ground (with the requested attack type). + * + * @param {string | undefined} attackType - The attack type requested. + * @return {boolean} Whether the entity is able to attack ground. + */ +Attack.prototype.CanAttackGround = function(attackType) +{ + if (attackType) + return "AttackGround" in this.template[attackType]; + + return this.GetAttackTypes().some(type => "AttackGround" in this.template[type]); +}; + Attack.prototype.CanAttack = function(target, wantedTypes) { + if (target instanceof Vector3D) + return this.CanAttackGround(); + let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) return true; @@ -357,6 +378,11 @@ */ Attack.prototype.GetBestAttackAgainst = function(target, allowCapture) { + // ToDo: Add support for more attack types based on some sort of preference + // (more splash is more better, DPS, range or something else?). + if (target instanceof Vector3D) + return this.GetAttackTypes().find(type => this.CanAttackGround(type)); + let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) { @@ -445,9 +471,12 @@ }; /** - * Attack the target entity. This should only be called after a successful range check, + * Attack the target. 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. + * + * @param {string} type - The type of the attack (e.g. "Melee", "Ranged", "Capture", "Slaughter"). + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. */ Attack.prototype.PerformAttack = function(type, target) { @@ -470,16 +499,23 @@ 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 predictedPosition; + if (typeof target == "number") + { + 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; + let timeToTarget = this.PredictTimeToTarget(selfPosition, horizSpeed, targetPosition, targetVelocity); + predictedPosition = (timeToTarget !== false) ? Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition) : targetPosition; + } + else + predictedPosition = target; // Add inaccuracy based on spread. let distanceModifiedSpread = ApplyValueModificationsToEntity("Attack/Ranged/Spread", +this.template[type].Projectile.Spread, this.entity) * @@ -489,11 +525,11 @@ let offsetX = randNorm[0] * distanceModifiedSpread; let offsetZ = randNorm[1] * distanceModifiedSpread; - let realTargetPosition = new Vector3D(predictedPosition.x + offsetX, targetPosition.y, predictedPosition.z + offsetZ); + let realTargetPosition = new Vector3D(predictedPosition.x + offsetX, predictedPosition.y, predictedPosition.z + offsetZ); // Recalculate when the missile will hit the target position. let realHorizDistance = realTargetPosition.horizDistanceTo(selfPosition); - timeToTarget = realHorizDistance / horizSpeed; + let timeToTarget = realHorizDistance / horizSpeed; let missileDirection = Vector3D.sub(realTargetPosition, selfPosition).div(realHorizDistance); 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 @@ -12,7 +12,7 @@ * @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 | Vector3D} data.target - Either the target entity ID, or a Vector3D of a position to attack. * @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. @@ -54,21 +54,27 @@ 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)) + let targetPosition; + if (typeof data.target == "number") { - cmpProjectileManager.RemoveProjectile(data.projectileId); - - Attacking.HandleAttackEffects(data.type, data.attackData, data.target, data.attacker, data.attackerOwner); - return; + // 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; + } + targetPosition = Attacking.InterpolatedLocation(data.target, lateness); } + else + targetPosition = data.target; - let targetPosition = Attacking.InterpolatedLocation(data.target, lateness); if (!targetPosition) return; - // If we didn't hit the main target look for nearby units. + // Look for nearby units. let ents = Attacking.EntitiesNearPoint(Vector2D.from3D(data.position), targetPosition.horizDistanceTo(data.position) * 2, Attacking.GetPlayersToDamage(data.attackerOwner, data.friendlyFire)); Index: binaries/data/mods/public/simulation/components/FormationAttack.js =================================================================== --- binaries/data/mods/public/simulation/components/FormationAttack.js +++ binaries/data/mods/public/simulation/components/FormationAttack.js @@ -21,46 +21,63 @@ return []; }; +/** + * Queries the individual attack ranges of members to get the "range" of this formation. + * @param {number} target - The target to attack. + * @return {Object} - The range of the formation in the form: + * "min": {number} - The minimum range to use. + * "max": {number} - The maximum range to use. + * "elevationBonus": {number} - The elevationBonus to use. + */ FormationAttack.prototype.GetRange = function(target) { - var result = {"min": 0, "max": this.canAttackAsFormation ? -1 : 0}; - var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); + let result = { + "min": 0, + "max": this.canAttackAsFormation ? -1 : 0, + "elevationBonus": this.canAttackAsFormation ? -1 : 0 + }; + let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (!cmpFormation) { warn("FormationAttack component used on a non-formation entity"); return result; } - var members = cmpFormation.GetMembers(); - for (var ent of members) + let members = cmpFormation.GetMembers(); + for (let ent of members) { - var cmpAttack = Engine.QueryInterface(ent, IID_Attack); + let cmpAttack = Engine.QueryInterface(ent, IID_Attack); if (!cmpAttack) continue; - var type = cmpAttack.GetBestAttackAgainst(target); + let type = cmpAttack.GetBestAttackAgainst(target); if (!type) continue; // if the formation can attack, take the minimum max range (so units are certainly in range), // If the formation can't attack, take the maximum max range as the point where the formation will be disbanded // Always take the minimum min range (to not get impossible situations) - var range = cmpAttack.GetRange(type); - + let range = cmpAttack.GetRange(type); if (this.canAttackAsFormation) { if (range.max < result.max || result.max < 0) result.max = range.max; + + if (range.elevationBonus < result.elevationBonus || result.max < 0) + result.elevationBonus = range.elevationBonus; } else { if (range.max > result.max || range.max < 0) result.max = range.max; + + if (range.elevationBonus > result.elevationBonus) + result.elevationBonus = range.elevationBonus; } if (range.min < result.min) result.min = range.min; } // add half the formation size, so it counts as the range for the units on the first row - var extraRange = cmpFormation.GetSize().depth/2; + let extraRange = cmpFormation.GetSize().depth / 2; if (result.max >= 0) result.max += extraRange; 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 @@ -457,6 +457,8 @@ // not in world, set a default? ret.attack[type].elevationAdaptedRange = ret.attack.maxRange; } + + ret.attack[type].attackGround = cmpAttack.CanAttackGround(type); } } Index: binaries/data/mods/public/simulation/components/UnitAI.js =================================================================== --- binaries/data/mods/public/simulation/components/UnitAI.js +++ binaries/data/mods/public/simulation/components/UnitAI.js @@ -438,7 +438,7 @@ if (this.IsAnimal()) this.SetNextState("ANIMAL.COMBAT.ATTACKING"); else - this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING"); + this.SetNextState("INDIVIDUAL.COMBAT"); return; } @@ -469,6 +469,18 @@ this.SetNextState("INDIVIDUAL.COMBAT.APPROACHING"); }, + "Order.AttackGround": function(msg) { + let type = this.GetBestAttackAgainst(this.order.data.target); + if (!type) + { + this.FinishOrder(); + return; + } + this.order.data.attackType = type; + + this.SetNextState("INDIVIDUAL.COMBAT"); + }, + "Order.Patrol": function(msg) { if (this.IsAnimal() || this.IsTurret()) { @@ -774,6 +786,24 @@ this.SetNextState("MEMBER"); }, + "Order.AttackGround": function(msg) { + let target = msg.data.target; + + // Check if we are already in range, otherwise walk there + if (!this.CheckFormationTargetAttackRange(target)) + { + this.SetNextState("COMBAT.APPROACHING"); + return; + } + this.CallMemberFunction("AttackGround", [target, false]); + + let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); + if (cmpAttack && cmpAttack.CanAttackAsFormation()) + this.SetNextState("COMBAT.ATTACKING"); + else + this.SetNextState("MEMBER"); + }, + "Order.Garrison": function(msg) { if (!Engine.QueryInterface(msg.data.target, IID_GarrisonHolder)) { @@ -1159,8 +1189,12 @@ }, "MovementUpdate": function(msg) { + let target = this.order.data.target; let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); - this.CallMemberFunction("Attack", [this.order.data.target, this.order.data.allowCapture, false]); + if (this.TargetIsEntity(target)) + this.CallMemberFunction("Attack", [target, this.order.data.allowCapture, false]); + else + this.CallMemberFunction("AttackGround", [target, false]); if (cmpAttack.CanAttackAsFormation()) this.SetNextState("COMBAT.ATTACKING"); else @@ -1173,13 +1207,25 @@ "enter": function(msg) { let target = this.order.data.target; let allowCapture = this.order.data.allowCapture; + let attackingEntity = this.TargetIsEntity(target); // Check if we are already in range, otherwise walk there if (!this.CheckFormationTargetAttackRange(target)) { - if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) + if (!attackingEntity || + this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { this.FinishOrder(); - this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); + if (attackEntity) + this.PushOrderFront("Attack", { + "target": target, + "force": false, + "allowCapture": allowCapture + }); + else + this.PushOrderFront("AttackGround", { + "target": target, + "force": false + }); return true; } this.FinishOrder(); @@ -1197,13 +1243,25 @@ "Timer": function(msg) { let target = this.order.data.target; let allowCapture = this.order.data.allowCapture; + let attackingEntity = this.TargetIsEntity(target); // Check if we are already in range, otherwise walk there if (!this.CheckFormationTargetAttackRange(target)) { - if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) + if (!attackingEntity || + this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { this.FinishOrder(); - this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); + if (attackEntity) + this.PushOrderFront("Attack", { + "target": target, + "force": false, + "allowCapture": allowCapture + }); + else + this.PushOrderFront("AttackGround", { + "target": target, + "force": false + }); return; } this.FinishOrder(); @@ -1213,7 +1271,7 @@ "leave": function(msg) { this.StopTimer(); - var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); + let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (cmpFormation) cmpFormation.SetRearrange(true); }, @@ -1812,6 +1870,43 @@ }, "COMBAT": { + "enter": function() { + let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); + if (cmpBuildingAI && !this.TargetIsEntity(this.order.data.target)) + { + warn("[UnitAI]: Buildings cannot attack ground yet."); + this.FinishOrder(); + return true; + } + // 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 true; + } + + this.SetNextState("ATTACKING"); + return true; + } + + // 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 true; + } + + this.SetNextState("APPROACHING"); + return false; + }, + "Order.LeaveFoundation": function(msg) { // Ignore the order as we're busy. return { "discardOrder": true }; @@ -1840,7 +1935,10 @@ if (!this.formationAnimationVariant) this.SetAnimationVariant("combat"); - this.StartTimer(1000, 1000); + // If attack ground is asked do not start the timer (ground does not run away). + if (this.TargetIsEntity(this.order.data.target)) + this.StartTimer(1000, 1000); + return false; }, @@ -1909,13 +2007,17 @@ "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) + let attackingEntity = this.TargetIsEntity(target); + if (attackingEntity) { - this.order.data.formationTarget = target; - target = cmpFormation.GetClosestMember(this.entity); - this.order.data.target = 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)) @@ -4514,6 +4616,15 @@ return cmpUnitMotion && cmpUnitMotion.MoveToTargetRange(target, 0, 1); }; +/** + * Move the entity so we hope the target is in range. + * + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @param {number} iid - The interface ID to check the range for. + * @param {string} type - The type for which the range is to be checked. + * + * @return {boolean} - Whether the order to move has succeeded. + */ UnitAI.prototype.MoveToTargetRange = function(target, iid, type) { if (!this.CheckTargetVisible(target) || this.IsTurret()) @@ -4524,17 +4635,27 @@ return false; let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); - return cmpUnitMotion && cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); + if (!cmpUnitMotion) + return false; + + if (this.TargetIsEntity(target)) + return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); + return cmpUnitMotion.MoveToPointRange(target.x, target.z, 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 + * 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. + * + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @param {string} type - The type of the attack which is to be used. + * + * @return {boolean} - Whether the order to move has succeeded. */ UnitAI.prototype.MoveToTargetAttackRange = function(target, type) { - // for formation members, the formation will take care of the range check + // For formation members, the formation will take care of the range check. if (this.IsFormationMember()) { let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); @@ -4542,9 +4663,13 @@ return false; } - let cmpFormation = Engine.QueryInterface(target, IID_Formation); - if (cmpFormation) - target = cmpFormation.GetClosestMember(this.entity); + let attackingEntity = this.TargetIsEntity(target); + if (attackingEntity) + { + let cmpFormation = Engine.QueryInterface(target, IID_Formation); + if (cmpFormation) + target = cmpFormation.GetClosestMember(this.entity); + } if (type != "Ranged") return this.MoveToTargetRange(target, IID_Attack, type); @@ -4556,22 +4681,23 @@ if (!range) return false; - let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); - if (!thisCmpPosition.IsInWorld()) + let cmpPositionSelf = Engine.QueryInterface(this.entity, IID_Position); + if (!cmpPositionSelf.IsInWorld()) return false; - let s = thisCmpPosition.GetPosition(); + let selfPosition = cmpPositionSelf.GetPosition(); - let targetCmpPosition = Engine.QueryInterface(target, IID_Position); - if (!targetCmpPosition.IsInWorld()) + let targetPosition = this.GetTargetPosition3D(target); + if (!targetPosition) return false; - let t = targetCmpPosition.GetPosition(); - // h is positive when I'm higher than the target - let h = s.y - t.y + range.elevationBonus; + // h Is positive when I'm higher than the target. + let h = selfPosition.y - targetPosition.y + range.elevationBonus; - let parabolicMaxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h); - // No negative roots please - if (h <= -range.max / 2) + let parabolicMaxRange; + // No negative roots please. + if (h > -range.max / 2) + parabolicMaxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h); + else // return false? Or hope you come close enough? parabolicMaxRange = 0; @@ -4579,7 +4705,12 @@ let guessedMaxRange = parabolicMaxRange > range.max ? (range.max + parabolicMaxRange) / 2 : parabolicMaxRange; let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); - return cmpUnitMotion && cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange); + if (!cmpUnitMotion) + return false; + + if (attackingEntity) + return cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange); + return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, guessedMaxRange); }; UnitAI.prototype.MoveToTargetRangeExplicit = function(target, min, max) @@ -4594,14 +4725,18 @@ /** * Move unit so we hope the target is in the attack range of the formation. * - * @param {number} target - The target entity ID to attack. + * @param {number | Vector3D} target - The target entity ID to attack, or a Vector3D of a position to attack. * @return {boolean} - Whether the order to move has succeeded. */ UnitAI.prototype.MoveFormationToTargetAttackRange = function(target) { - let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation); - if (cmpTargetFormation) - target = cmpTargetFormation.GetClosestMember(this.entity); + let attackingEntity = this.TargetIsEntity(target); + if (attackingEntity) + { + let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation); + if (cmpTargetFormation) + target = cmpTargetFormation.GetClosestMember(this.entity); + } if (!this.CheckTargetVisible(target) || this.IsTurret()) return false; @@ -4612,7 +4747,12 @@ let range = cmpFormationAttack.GetRange(target); let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); - return cmpUnitMotion && cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); + if (!cmpUnitMotion) + return false; + + if (attackingEntity) + return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); + return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, range.max); }; UnitAI.prototype.MoveToGarrisonRange = function(target) @@ -4657,6 +4797,15 @@ return cmpObstructionManager.IsInPointRange(this.entity, x, z, min, max, false); }; +/** + * Check if the target is inside the range. + * + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @param {iid} number - The type of the interface which ought to be queried. + * @param {string} type - The type of the attack which is to be used. + * + * @return {boolean} - Whether the location is within range. + */ UnitAI.prototype.CheckTargetRange = function(target, iid, type) { let range = this.GetRange(iid, type); @@ -4664,18 +4813,26 @@ return false; let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); - return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false); + let attackEntity = this.TargetIsEntity(target); + if (attackEntity) + return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false); + return cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, 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 + * Check if the target is inside the attack range. + * For melee attacks, this goes straight 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 + * when the target is lower, and smaller ranges when the target is higher. + * + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @param {string} type - The type of the attack which is to be used. + * + * @return {boolean} - Whether the attack-location is within attacking distance. */ UnitAI.prototype.CheckTargetAttackRange = function(target, type) { - // for formation members, the formation will take care of the range check + // For formation members, the formation will take care of the range check. if (this.IsFormationMember()) { let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); @@ -4684,37 +4841,41 @@ return true; } - let cmpFormation = Engine.QueryInterface(target, IID_Formation); - if (cmpFormation) - target = cmpFormation.GetClosestMember(this.entity); + let attackingEntity = this.TargetIsEntity(target); + if (attackingEntity) + { + 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()) + let targetPosition = this.GetTargetPosition3D(target); + if (!targetPosition) return false; let range = this.GetRange(IID_Attack, type); if (!range) return false; - let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); - if (!thisCmpPosition.IsInWorld()) + let cmpPositionSelf = Engine.QueryInterface(this.entity, IID_Position); + if (!cmpPositionSelf || !cmpPositionSelf.IsInWorld()) return false; - let s = thisCmpPosition.GetPosition(); + let selfPosition = cmpPositionSelf.GetPosition(); - let t = targetCmpPosition.GetPosition(); - - let h = s.y - t.y + range.elevationBonus; + let h = selfPosition.y - targetPosition.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); + if (attackingEntity) + return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, maxRange, false); + return cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, maxRange, false); }; UnitAI.prototype.CheckTargetRangeExplicit = function(target, min, max) @@ -4726,14 +4887,18 @@ /** * Check if the target is inside the attack range of the formation. * - * @param {number} target - The target entity ID to attack. + * @param {number | Vector3D} target - The target entity ID to attack, or a Vector3D of a position to attack. * @return {boolean} - Whether the entity is within attacking distance. */ UnitAI.prototype.CheckFormationTargetAttackRange = function(target) { - let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation); - if (cmpTargetFormation) - target = cmpTargetFormation.GetClosestMember(this.entity); + let attackingEntity = this.TargetIsEntity(target); + if (attackingEntity) + { + let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation); + if (cmpTargetFormation) + target = cmpTargetFormation.GetClosestMember(this.entity); + } let cmpFormationAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpFormationAttack) @@ -4741,7 +4906,9 @@ let range = cmpFormationAttack.GetRange(target); let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); - return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false); + if (attackingEntity) + return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false); + return cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, range.max, false); }; UnitAI.prototype.CheckGarrisonRange = function(target) @@ -4761,19 +4928,26 @@ /** * Returns true if the target entity is visible through the FoW/SoD. + * + * @param {number | Vector3D} target - Either an entity ID, or a Vector3D of a position. + * @return {boolean} - Whether the target is visible. */ UnitAI.prototype.CheckTargetVisible = function(target) { - var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); + // Assume an attackground target is either visible or not important whether it is. + if (!this.TargetIsEntity(target)) + return true; + + let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership) return false; - var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); + let 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); + // Entities that are hidden and miraged are considered visible. + let cmpFogging = Engine.QueryInterface(target, IID_Fogging); if (cmpFogging && cmpFogging.IsMiraged(cmpOwnership.GetOwner())) return true; @@ -4826,13 +5000,11 @@ */ UnitAI.prototype.FaceTowardsTarget = function(target) { - let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); - if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) + let targetPosition = this.GetTargetPosition2D(target); + if (!targetPosition) return; - let targetPosition = cmpTargetPosition.GetPosition2D(); - - // Use cmpUnitMotion for units that support that, otherwise try cmpPosition (e.g. turrets) + // Use cmpUnitMotion for units that support that, otherwise try cmpPosition (e.g. turrets). let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion) { @@ -5009,7 +5181,7 @@ */ UnitAI.prototype.ShouldChaseTargetedEntity = function(target, force) { - if (this.IsTurret()) + if (!this.TargetIsEntity(target) || this.IsTurret()) return false; if (this.GetStance().respondChase) @@ -5079,6 +5251,45 @@ this.PushOrderFront("MoveIntoFormation", { "x": pos.x, "z": pos.z, "force": true }); }; +/* + * Returns the 3D target position. + * + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @return {Vector3D} - The 3D-position of the target. + */ +UnitAI.prototype.GetTargetPosition3D = function(target) +{ + if (this.TargetIsEntity(target)) + { + let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); + if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) + return undefined; + + return cmpTargetPosition.GetPosition(); + } + + return target; +}; + +/* + * Returns the 2D target position. + * + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @return {Vector3D} - The 3D-position of the target. + */ +UnitAI.prototype.GetTargetPosition2D = function(target) +{ + if (this.TargetIsEntity(target)) + { + let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); + if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) + return undefined; + + return cmpTargetPosition.GetPosition2D(); + } + return Vector2D.from3D(target); +}; + UnitAI.prototype.GetTargetPositions = function() { var targetPositions = []; @@ -5096,6 +5307,10 @@ targetPositions.push(new Vector2D(order.data.x, order.data.z)); break; // and continue the loop + case "AttackGround": + targetPositions.push(Vector2D.from3D(order.data.target)); + return targetPositions; + case "WalkToTarget": case "WalkToTargetRange": // This doesn't move to the target (just into range), but a later order will. case "Guard": @@ -5381,6 +5596,23 @@ }; /** + * Adds AttackGround order to queue, forced by the player. + * + * @param {Vector3D} target - The x,y,z-values where the entities need to attack ground. + * @param {number} radius - The radius from the target in which the entities need to attack ground. + * @param {boolean} queued - Whether the order is queued or not. + */ +UnitAI.prototype.AttackGround = function(target, radius, queued) +{ + if (this.CanAttack(target)) + this.AddOrder("AttackGround", { + "target": target, + "radius": radius, + "force": true + }, queued); +}; + +/** * Adds garrison order to the queue, forced by the player. */ UnitAI.prototype.Garrison = function(target, queued) @@ -5985,9 +6217,9 @@ { if (!orderData) orderData = this.order.data; - let cmpPosition = Engine.QueryInterface(orderData.target, IID_Position); - if (cmpPosition && cmpPosition.IsInWorld()) - orderData.lastPos = cmpPosition.GetPosition(); + let targetPosition = this.GetTargetPosition3D(orderData.target); + if (targetPosition) + orderData.lastPos = targetPosition; }; UnitAI.prototype.SetHeldPosition = function(x, z) @@ -6038,7 +6270,16 @@ return undefined; return component.GetRange(type); -} +}; + +/** + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @return {boolean} - Whether the target is an entity. + */ +UnitAI.prototype.TargetIsEntity = function(target) +{ + return typeof target == "number"; +}; UnitAI.prototype.CanAttack = function(target) { Index: binaries/data/mods/public/simulation/components/tests/test_Attack.js =================================================================== --- binaries/data/mods/public/simulation/components/tests/test_Attack.js +++ binaries/data/mods/public/simulation/components/tests/test_Attack.js @@ -102,7 +102,8 @@ "Multiplier": 3 } } - } + }, + "AttackGround": {} }, "Capture": { "Capture": 8, @@ -153,6 +154,10 @@ TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetFullAttackRange(), { "min": 0, "max": 80 }); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Capture"), { "Capture": 8 }); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.CanAttackGround("Capture"), false); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.CanAttackGround("Ranged"), true); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.CanAttackGround(), true); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Ranged"), { "Damage": { "Hack": 0, Index: binaries/data/mods/public/simulation/helpers/Commands.js =================================================================== --- binaries/data/mods/public/simulation/helpers/Commands.js +++ binaries/data/mods/public/simulation/helpers/Commands.js @@ -192,6 +192,21 @@ }); }, + "attack-ground": function(player, cmd, data) + { + let entities = []; + let target = new Vector3D(cmd.target.x, cmd.target.y, cmd.target.z); + for (let ent of data.entities) + { + let cmpAttack = Engine.QueryInterface(ent, IID_Attack); + if (cmpAttack && cmpAttack.CanAttackGround()) + entities.push(ent); + } + GetFormationUnitAIs(entities, player).forEach(cmpUnitAI => { + cmpUnitAI.AttackGround(target, cmd.queued); + }); + }, + "patrol": function(player, cmd, data) { let allowCapture = cmd.allowCapture || cmd.allowCapture == null; Index: binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged.xml =================================================================== --- binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged.xml +++ binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged.xml @@ -25,6 +25,7 @@ Human +