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 @@ -254,6 +254,8 @@ let tooltips = []; for (let type in template.attack) { + if (type == "attackGround") + continue; // Because the ability to attack ground is not per se set per type... if (type == "Slaughter") continue; // Slaughter is used to kill animals, so do not show it. 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; @@ -899,6 +900,10 @@ var action = determineAction(ev.x, ev.y); if (!action) break; + + if (preSelectedAction == ACTION_ATTACKGROUND) + action.radius = g_AttackGroundSize; + if (!Engine.HotkeyIsPressed("session.queue")) { preSelectedAction = ACTION_NONE; @@ -912,6 +917,18 @@ inputState = INPUT_NORMAL; break; } + + case "hotkeydown": + if (preSelectedAction == ACTION_ATTACKGROUND && ev.hotkey == "camera.zoom.wheel.in" || ev.hotkey == "camera.zoom.in") + { + OnAttackGroundMouseWheel(-1); + warn("Called down! " + uneval(g_AttackGroundSize)); + } + else if (preSelectedAction == ACTION_ATTACKGROUND && ev.hotkey == "camera.zoom.wheel.out" || ev.hotkey == "camera.zoom.out") + { + OnAttackGroundMouseWheel(1); + warn("Called up! " + uneval(g_AttackGroundSize)); + } // else default: // Slight hack: If selection is empty, reset the input state @@ -1322,6 +1339,34 @@ } } +// Attack ground: +// When the user uses the hotkey the radius of the bombarded area is increased/decreased. +var g_AttackGroundSize = getDefaultAttackGroundSize(); +function OnAttackGroundMouseWheel(dir) +{ + g_AttackGroundSize += dir / +(Engine.ConfigDB_GetValue("user", "gui.session.scrollattackgroundsizeratio") || 1); + if (g_AttackGroundSize < 1 || !Number.isFinite(g_AttackGroundSize)) + g_AttackGroundSize = 1; + + updateSelectionDetails(); +} + +function getDefaultAttackGroundSize() +{ + let num = +Engine.ConfigDB_GetValue("user", "gui.session.attackgroundsize"); + return Number.isInteger(num) && num > 0 ? num : 5; +} + +function getAttackGroundSize() +{ + return Math.max(Math.round(g_AttackGroundSize), 1); +} + +function updateDefaultAttackGroundSize() +{ + g_AttackGroundSize = getDefaultAttackGroundSize(); +} + // Batch training: // When the user shift-clicks, we set these variables and switch to INPUT_BATCHTRAINING // When the user releases shift, or clicks on a different training button, we create the batched units 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 @@ -236,6 +236,40 @@ "specificness": 10, }, + "attack-ground": { + "execute": function(target, action, selection, queued) + { + Engine.PostNetworkCommand({ + "type": "attack-ground", + "entities": selection, + "target": target,, + "radius": action.radius + "queued": queued + }); + + DrawTargetMarker(target); + + return true; + }, + "getActionInfo": function(selection, target) + { + return { "possible": true }; + }, + "preSelectedActionCheck": function(target, selection) + { + if (preSelectedAction != ACTION_ATTACKGROUND || !getActionInfo("attack-ground", target, selection).possible) + return false; + + return { + "type": "attack-ground", + "cursor": "action-attack", + "target": target + }; + }, + "specificness": 50, + }, + + "patrol": { "execute": function(target, action, selection, queued) @@ -1095,6 +1129,25 @@ }, }, + "attack-ground": { + "getInfo": function(entStates) + { + if (entStates.every(entState => !entState.attack || !entState.attack.attackGround)) + return false; + + return { + "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.attackground") + + translate("Attack the selected ground."), + "icon": "attack-request.png" + }; + }, + "execute": function(entStates) + { + inputState = INPUT_PRESELECTEDACTION; + preSelectedAction = ACTION_ATTACKGROUND; + }, + }, + "garrison": { "getInfo": function(entStates) { @@ -1513,7 +1566,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 @@ -111,6 +111,7 @@ "10.0" + "0.0" + "" + + "" + "" + "" + "1000.0" + @@ -178,6 +179,9 @@ "" + "" + "" + + ""+ + "" + + "" + "" + "" + "" + @@ -261,6 +265,14 @@ (!wantedTypesReal || !wantedTypesReal.length || wantedTypesReal.indexOf(type) != -1)); }; +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.GetPreferredClasses = function(type) { if (this.template[type] && this.template[type].PreferredClasses && @@ -281,6 +293,9 @@ Attack.prototype.CanAttack = function(target, wantedTypes) { + if (target instanceof Vector3D) + return this.CanAttackGround(); + let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) return true; @@ -381,6 +396,10 @@ 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) { @@ -504,12 +523,15 @@ }; /** - * 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) { + let attackGround = target instanceof Vector3D; let attackerOwner = Engine.QueryInterface(this.entity, IID_Ownership).GetOwner(); let cmpDamage = Engine.QueryInterface(SYSTEM_ENTITY, IID_Damage); @@ -530,16 +552,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 (attackGround) + predictedPosition = target; + else + { + 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 ? 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) * @@ -549,11 +578,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/Damage.js =================================================================== --- binaries/data/mods/public/simulation/components/Damage.js +++ binaries/data/mods/public/simulation/components/Damage.js @@ -82,7 +82,7 @@ * 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 {number | Vector3D} data.target - Either the target entity ID, or a Vector3D of a position to attack. * @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. @@ -106,6 +106,8 @@ if (!data.position) return; + let attackGround = data.target instanceof Vector3D; + let cmpSoundManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_SoundManager); if (cmpSoundManager && data.attackImpactSound) cmpSoundManager.PlaySoundGroupAtPosition(data.attackImpactSound, data.position); @@ -129,27 +131,32 @@ 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)) + if (!attackGround) { - 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); + // 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; + return; + } } - let targetPosition = this.InterpolatedLocation(data.target, lateness); + let targetPosition = attackGround ? data.target : + this.InterpolatedLocation(data.target, lateness); + if (!targetPosition) return; - // If we didn't hit the main target look for nearby units + // 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) 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 @@ -23,44 +23,53 @@ 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 @@ -430,6 +430,8 @@ ret.attack[type].elevationAdaptedRange = ret.attack.maxRange; } } + + ret.attack.attackGround = cmpAttack.CanAttackGround(); } let cmpArmour = Engine.QueryInterface(ent, IID_DamageReceiver); 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 @@ -393,7 +393,7 @@ } // Work out how to attack the given target - var type = this.GetBestAttackAgainst(this.order.data.target, this.order.data.allowCapture); + let type = this.GetBestAttackAgainst(this.order.data.target, this.order.data.allowCapture); if (!type) { // Oops, we can't attack at all @@ -414,34 +414,34 @@ return; } - if (this.IsAnimal()) - this.SetNextState("ANIMAL.COMBAT.ATTACKING"); - else - this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING"); + this.IsAnimal() ? this.SetNextState("ANIMAL.COMBAT.ATTACKING") : + this.SetNextState("INDIVIDUAL.COMBAT"); 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.IsAnimal() ? this.SetNextState("ANIMAL.COMBAT.APPROACHING") : + this.SetNextState("INDIVIDUAL.COMBAT"); + }, + + "Order.AttackGround": function(msg) { + // Work out how to attack the given target + let target = this.order.data.target; + let type = this.GetBestAttackAgainst(target); + if (!type) { + // Oops, we can't attack at all this.FinishOrder(); return; } + this.order.data.attackType = type; - // 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; - } + // Distribute the attacks. + let randNorm = randomNormal2D(); + let offsetX = randNorm[0] * this.order.data.radius; + let offsetZ = randNorm[1] * this.order.data.radius; + this.order.data.target = new Vector3D(target.x + offsetX, target.y, target.z + offsetZ); - if (this.IsAnimal()) - this.SetNextState("ANIMAL.COMBAT.APPROACHING"); - else - this.SetNextState("INDIVIDUAL.COMBAT.APPROACHING"); + this.SetNextState("INDIVIDUAL.COMBAT"); }, "Order.Patrol": function(msg) { @@ -697,15 +697,15 @@ }, "Order.Attack": function(msg) { - var target = msg.data.target; - var allowCapture = msg.data.allowCapture; - var cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI); + let target = msg.data.target; + let allowCapture = msg.data.allowCapture; + let cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI); if (cmpTargetUnitAI && cmpTargetUnitAI.IsFormationMember()) target = cmpTargetUnitAI.GetFormationController(); - var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); + let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); // Check if we are already in range, otherwise walk there - if (!this.CheckTargetAttackRange(target, target)) + if (!this.CheckFormationTargetAttackRange(target)) { if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { @@ -716,9 +716,22 @@ return; } this.CallMemberFunction("Attack", [target, allowCapture, false]); - if (cmpAttack.CanAttackAsFormation()) - this.SetNextState("COMBAT.ATTACKING"); - else + cmpAttack.CanAttackAsFormation() ? this.SetNextState("COMBAT.ATTACKING") : + this.SetNextState("MEMBER"); + }, + + "Order.AttackGround": function(msg) { + let target = msg.data.target; + + let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); + // Check if we are already in range, otherwise walk there + if (!this.CheckFormationTargetAttackRange(target)) + { + this.SetNextState("COMBAT.APPROACHING"); + return; + } + this.CallMemberFunction("AttackGround", [target, msg.data.radius, false]); + cmpAttack.CanAttackAsFormation() ? this.SetNextState("COMBAT.ATTACKING") : this.SetNextState("MEMBER"); }, @@ -1082,14 +1095,17 @@ "COMBAT": { "APPROACHING": { "enter": function() { - if (!this.MoveTo(this.order.data)) + if (!this.MoveFormationToTargetAttackRange(this.order.data.target)) { this.FinishOrder(); return true; } let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); - cmpFormation.SetRearrange(true); - cmpFormation.MoveMembersIntoFormation(true, true); + if (cmpFormation) + { + cmpFormation.SetRearrange(true); + cmpFormation.MoveMembersIntoFormation(true, true); + } }, "leave": function() { @@ -1097,9 +1113,13 @@ }, "MovementUpdate": function(msg) { + let target = this.order.data.target; + let attackGround = target instanceof Vector3D; let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); - this.CallMemberFunction("Attack", [this.order.data.target, this.order.data.allowCapture, false]); - if (cmpAttack.CanAttackAsFormation()) + attackGround ? this.CallMemberFunction("AttackGround", [target, false]) : + this.CallMemberFunction("Attack", [target, this.order.data.allowCapture, false]); + + if (cmpAttack && cmpAttack.CanAttackAsFormation()) this.SetNextState("COMBAT.ATTACKING"); else this.SetNextState("MEMBER"); @@ -1109,39 +1129,46 @@ "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)) + let target = this.order.data.target; + let attackGround = target instanceof Vector3D; + let allowCapture = this.order.data.allowCapture; + // Check if we are already in range, otherwise walk there. + if (!this.CheckFormationTargetAttackRange(target)) { - if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) + if (this.TargetIsAlive(target) && this.CheckTargetVisible(target) || attackGround) { this.FinishOrder(); - this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); + attackGround ? this.PushOrderFront("AttackGround", { "target": target, "force": false }) : + this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); return true; } this.FinishOrder(); return true; } - var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); + let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); // TODO fix the rearranging while attacking as formation - cmpFormation.SetRearrange(!this.IsAttackingAsFormation()); - cmpFormation.MoveMembersIntoFormation(false, false); + if (cmpFormation) + { + 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)) + let target = this.order.data.target; + let attackGround = target instanceof Vector3D; + let allowCapture = this.order.data.allowCapture; + // Check if we are already in range, otherwise walk there. + if (!this.CheckFormationTargetAttackRange(target)) { if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { this.FinishOrder(); - this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); + attackGround ? this.PushOrderFront("AttackGround", { "target": target, "force": false }) : + this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); return; } this.FinishOrder(); @@ -1151,7 +1178,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); }, @@ -1722,6 +1749,43 @@ }, "COMBAT": { + "enter": function() { + // 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; + } + + this.SetNextState("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; + } + + this.SetNextState("APPROACHING"); + }, + "Order.LeaveFoundation": function(msg) { // Ignore the order as we're busy. return { "discardOrder": true }; @@ -1746,7 +1810,10 @@ this.SetAnimationVariant("combat"); this.SelectAnimation("move"); - this.StartTimer(1000, 1000); + + // If attack ground is asked do not start the timer (ground does not run away). + if (!(this.order.data.target instanceof Vector3D)) + this.StartTimer(1000, 1000); }, "leave": function() { @@ -1783,53 +1850,56 @@ "ATTACKING": { "enter": function() { - var target = this.order.data.target; - var cmpFormation = Engine.QueryInterface(target, IID_Formation); - // if the target is a formation, save the attacking formation, and pick a member - if (cmpFormation) + let target = this.order.data.target; + let attackGround = target instanceof Vector3D; + + if (!attackGround) { - 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; + } } - // Check the target is still alive and attackable - if (this.CanAttack(target) && !this.CheckTargetAttackRange(target, this.order.data.attackType)) + + // Check the target is still alive and attackable. + // If out of range, try to chase after it. + if (this.CanAttack(target) && !this.CheckTargetAttackRange(target, this.order.data.attackType) && this.ShouldChaseTarget(target, this.order.data.force)) { - // Can't reach it - try to chase after it - if (this.ShouldChaseTargetedEntity(target, this.order.data.force)) + if (this.CanPack()) { - if (this.CanPack()) - { - this.PushOrderFront("Pack", { "force": true }); - return; - } - - this.SetNextState("COMBAT.CHASING"); - return true; + this.PushOrderFront("Pack", { "force": true }); + return; } + + this.SetNextState("COMBAT.CHASING"); + return true; } this.StopMoving(); - var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); + 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. - var prepare = this.attackTimers.prepare; + let prepare = this.attackTimers.prepare; if (this.lastAttacked) { - var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); - var repeatLeft = this.lastAttacked + this.attackTimers.repeat - cmpTimer.GetTime(); + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + let repeatLeft = this.lastAttacked + this.attackTimers.repeat - cmpTimer.GetTime(); prepare = Math.max(prepare, repeatLeft); } this.oldAttackType = this.order.data.attackType; // add prefix + no capital first letter for attackType - var animationName = "attack_" + this.order.data.attackType.toLowerCase(); + let animationName = "attack_" + this.order.data.attackType.toLowerCase(); if (this.IsFormationMember()) { - var cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); + let cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) animationName = cmpFormation.GetFormationAnimation(this.entity, animationName); } @@ -1844,13 +1914,19 @@ this.FaceTowardsTarget(this.order.data.target); - var cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); + let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); + // Attack ground with BuildingAI not yet implemented. + if (cmpBuildingAI && attackGround) + { + this.FinishOrder(); + return true; + } if (cmpBuildingAI) cmpBuildingAI.SetUnitAITarget(this.order.data.target); }, "leave": function() { - var cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); + let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); if (cmpBuildingAI) cmpBuildingAI.SetUnitAITarget(0); this.StopTimer(); @@ -1858,18 +1934,23 @@ }, "Timer": function(msg) { - var target = this.order.data.target; - var cmpFormation = Engine.QueryInterface(target, IID_Formation); - // if the target is a formation, save the attacking formation, and pick a member - if (cmpFormation) + let target = this.order.data.target; + let attackGround = target instanceof Vector3D; + + if (!attackGround) { - var thisObject = this; - var filter = function(t) { - return thisObject.CanAttack(t); - }; - this.order.data.formationTarget = target; - target = cmpFormation.GetClosestMember(this.entity, filter); - 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) + { + let thisObject = this; + let filter = function(t) { + return thisObject.CanAttack(t); + }; + this.order.data.formationTarget = target; + target = cmpFormation.GetClosestMember(this.entity, filter); + this.order.data.target = target; + } } // Check the target is still alive and attackable if (this.CanAttack(target)) @@ -1877,7 +1958,7 @@ // If we are hunting, first update the target position of the gather order so we know where will be the killed animal if (this.order.data.hunting && this.orderQueue[1] && this.orderQueue[1].data.lastPos) { - var cmpPosition = Engine.QueryInterface(this.order.data.target, IID_Position); + let cmpPosition = Engine.QueryInterface(this.order.data.target, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) { // Store the initial position, so that we can find the rest of the herd later @@ -1889,13 +1970,13 @@ } } - var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); this.lastAttacked = cmpTimer.GetTime() - msg.lateness; this.FaceTowardsTarget(target); // BuildingAI has it's own attack-routine - var cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); + let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); if (!cmpBuildingAI) { let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); @@ -1914,7 +1995,7 @@ } // Can't reach it - try to chase after it - if (this.ShouldChaseTargetedEntity(target, this.order.data.force)) + if (this.ShouldChaseTarget(target, this.order.data.force)) { if (this.CanPack()) { @@ -1924,10 +2005,15 @@ this.SetNextState("COMBAT.CHASING"); return; } + else if (this.MoveToTargetAttackRange(target, this.order.data.attackType)) + { + this.SetNextState("COMBAT.APPROACHING"); + return; + } } // if we're targetting a formation, find a new member of that formation - var cmpTargetFormation = Engine.QueryInterface(this.order.data.formationTarget || INVALID_ENTITY, IID_Formation); + let cmpTargetFormation = Engine.QueryInterface(this.order.data.formationTarget || INVALID_ENTITY, IID_Formation); // if there is no target, it means previously searching for the target inside the target formation failed, so don't repeat the search if (target && cmpTargetFormation) { @@ -1937,7 +2023,7 @@ } // 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 ennemies around before moving again + // Except if in WalkAndFight mode where we look for more enemies around before moving again if (this.FinishOrder()) { if (this.IsWalkingAndFighting()) @@ -2377,7 +2463,7 @@ return; } // Can't reach it - try to chase after it - if (this.ShouldChaseTargetedEntity(target, this.order.data.force)) + if (this.ShouldChaseTarget(target, this.order.data.force)) { if (this.CanPack()) { @@ -4145,76 +4231,88 @@ UnitAI.prototype.MoveToTargetRange = function(target, iid, type) { + let attackGround = target instanceof Vector3D; + if (!this.CheckTargetVisible(target) || this.IsTurret()) return false; - var cmpRanged = Engine.QueryInterface(this.entity, iid); + let cmpRanged = Engine.QueryInterface(this.entity, iid); if (!cmpRanged) return false; - var range = cmpRanged.GetRange(type); + let range = cmpRanged.GetRange(type); - var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); - return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); + let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); + return attackGround ? cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, range.max, true) : + 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 + * + * @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) { + let attackGround = target instanceof Vector3D; // for formation members, the formation will take care of the range check if (this.IsFormationMember()) { - var cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); + let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); if (cmpFormationUnitAI && cmpFormationUnitAI.IsAttackingAsFormation()) return false; } - var cmpFormation = Engine.QueryInterface(target, IID_Formation); - if (cmpFormation) - target = cmpFormation.GetClosestMember(this.entity); + if (!attackGround) + { + 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)) + if (!attackGround && !this.CheckTargetVisible(target)) return false; - var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); - var range = cmpAttack.GetRange(type); + let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); + let range = cmpAttack.GetRange(type); - var thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); - if (!thisCmpPosition.IsInWorld()) + let cmpThisPosition = Engine.QueryInterface(this.entity, IID_Position); + if (!cmpThisPosition.IsInWorld()) return false; - var s = thisCmpPosition.GetPosition(); + let selfPosition = cmpThisPosition.GetPosition(); - var targetCmpPosition = Engine.QueryInterface(target, IID_Position); - if (!targetCmpPosition.IsInWorld()) + let targetPosition = this.GetTargetPosition3D(target); + if (!targetPosition) return false; - var t = targetCmpPosition.GetPosition(); - // h is positive when I'm higher than the target - var 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; - // No negative roots please - if (h>-range.max/2) - var parabolicMaxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h); - else - // return false? Or hope you come close enough? - var parabolicMaxRange = 0; - //return false; + // No negative roots please. + let parabolicMaxRange = 0; + if (h > -range.max / 2) + parabolicMaxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h); - // the parabole changes while walking, take something in the middle - var guessedMaxRange = (range.max + parabolicMaxRange)/2; + // The parabole changes while walking, take something in the middle. + let guessedMaxRange = (range.max + parabolicMaxRange) / 2; - var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); - if (cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange)) - return true; + let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); + if (!cmpUnitMotion) + return false; - // if that failed, try closer - return cmpUnitMotion.MoveToTargetRange(target, range.min, Math.min(range.max, parabolicMaxRange)); + // If guessedMaxRange fails, try closer. + if (attackGround) + return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, guessedMaxRange) || + cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, Math.min(range.max, parabolicMaxRange)); + + return cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange) || + cmpUnitMotion.MoveToTargetRange(target, range.min, Math.min(range.max, parabolicMaxRange)); }; UnitAI.prototype.MoveToTargetRangeExplicit = function(target, min, max) @@ -4226,6 +4324,45 @@ return cmpUnitMotion.MoveToTargetRange(target, min, max); }; +/** + * Move unit so we hope the target is in the attack range of the formation. + * + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @return {boolean} - Whether the order to move has succeeded? + */ +UnitAI.prototype.MoveFormationToTargetAttackRange = function(target) +{ + let attackGround = target instanceof Vector3D; + // 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; + } + + if (!attackGround) + { + let cmpFormation = Engine.QueryInterface(target, IID_Formation); + if (cmpFormation) + target = cmpFormation.GetClosestMember(this.entity); + } + + if (!this.CheckTargetVisible(target) || this.IsTurret()) + return false; + + let cmpFormationAttack = Engine.QueryInterface(this.entity, IID_Attack); + if (!cmpFormationAttack) + return false; + let range = cmpFormationAttack.GetRange(target); + + let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); + if (cmpUnitMotion) + return attackGround ? cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, range.max, true) : + cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); + return false; +}; + UnitAI.prototype.MoveToGarrisonRange = function(target) { if (!this.CheckTargetVisible(target)) @@ -4268,64 +4405,83 @@ return cmpObstructionManager.IsInPointRange(this.entity, x, z, min, max, true); }; +/** + * 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 querried. + * @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) { - var cmpRanged = Engine.QueryInterface(this.entity, iid); + let attackGround = target instanceof Vector3D; + let cmpRanged = Engine.QueryInterface(this.entity, iid); if (!cmpRanged) return false; - var range = cmpRanged.GetRange(type); + let range = cmpRanged.GetRange(type); let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); - return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, true); + return attackGround ? cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, range.max, true) : + cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, true); }; /** * Check if the target is inside the attack range - * For melee attacks, this goes straigt to the regular range calculation + * 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 + * + * @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) { + let attackGround = target instanceof Vector3D; // for formation members, the formation will take care of the range check if (this.IsFormationMember()) { - var cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); + let cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); if (cmpFormationUnitAI && cmpFormationUnitAI.IsAttackingAsFormation() && cmpFormationUnitAI.order.data.target == target) return true; } - var cmpFormation = Engine.QueryInterface(target, IID_Formation); - if (cmpFormation) - target = cmpFormation.GetClosestMember(this.entity); + if (!attackGround) + { + let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation); + if (cmpTargetFormation) + target = cmpTargetFormation.GetClosestMember(this.entity); + } if (type != "Ranged") return this.CheckTargetRange(target, IID_Attack, type); - var targetCmpPosition = Engine.QueryInterface(target, IID_Position); - if (!targetCmpPosition || !targetCmpPosition.IsInWorld()) + let targetPosition = this.GetTargetPosition3D(target); + if (!targetPosition) return false; - var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); - var range = cmpAttack.GetRange(type); + let range; + let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); + if (cmpAttack) + range = cmpAttack.GetRange(type); - var thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); - if (!thisCmpPosition.IsInWorld()) + let cmpThisPosition = Engine.QueryInterface(this.entity, IID_Position); + if (!cmpThisPosition.IsInWorld()) return false; - var s = thisCmpPosition.GetPosition(); - - var t = targetCmpPosition.GetPosition(); + let selfPosition = cmpThisPosition.GetPosition(); - var h = s.y-t.y+range.elevationBonus; - var maxRangeSq = 2*range.max*(h + range.max/2); + let h = selfPosition.y - targetPosition.y + range.elevationBonus; + let maxRangeSq = 2 * range.max * (h + range.max / 2); if (maxRangeSq < 0) return false; let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); - return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, Math.sqrt(maxRangeSq), true); + return attackGround ? cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, Math.sqrt(maxRangeSq), true) : + cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, Math.sqrt(maxRangeSq), true); }; UnitAI.prototype.CheckTargetRangeExplicit = function(target, min, max) @@ -4334,6 +4490,42 @@ return cmpObstructionManager.IsInTargetRange(this.entity, target, min, max, true); }; +/** + * Check if the target is inside the attack range of the formation. + * + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @return {boolean} - Whether the attack-location is within attacking distance. + */ +UnitAI.prototype.CheckFormationTargetAttackRange = function(target) +{ + let attackGround = target instanceof Vector3D; + // 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; + } + + if (!attackGround) + { + let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation); + if (cmpTargetFormation) + target = cmpTargetFormation.GetClosestMember(this.entity); + } + + let cmpFormationAttack = Engine.QueryInterface(this.entity, IID_Attack); + if (!cmpFormationAttack) + return false; + let range = cmpFormationAttack.GetRange(target); + + let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); + return attackGround ? cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, range.max, true) : + cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, true); +}; + + UnitAI.prototype.CheckGarrisonRange = function(target) { var cmpGarrisonHolder = Engine.QueryInterface(target, IID_GarrisonHolder); @@ -4354,16 +4546,20 @@ */ UnitAI.prototype.CheckTargetVisible = function(target) { - var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); + // Assume an attackground target is 'visible' or not important whether it is. + if (target instanceof Vector3D) + 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); + let cmpFogging = Engine.QueryInterface(target, IID_Fogging); if (cmpFogging && cmpFogging.IsMiraged(cmpOwnership.GetOwner())) return true; @@ -4376,22 +4572,16 @@ UnitAI.prototype.FaceTowardsTarget = function(target) { - var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); + let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return; - var cmpTargetPosition = Engine.QueryInterface(target, IID_Position); - if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) + + let targetPosition = this.GetTargetPosition2D(target); + if (!targetPosition) return; - var targetpos = cmpTargetPosition.GetPosition2D(); - var angle = cmpPosition.GetPosition2D().angleTo(targetpos); - var rot = cmpPosition.GetRotation(); - var delta = (rot.y - angle + Math.PI) % (2 * Math.PI) - Math.PI; - if (Math.abs(delta) > 0.2) - { - var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); - if (cmpUnitMotion) - cmpUnitMotion.FaceTowardsPoint(targetpos.x, targetpos.y); - } + + let pos = targetPosition.sub(cmpPosition.GetPosition2D()); + cmpPosition.TurnTo(Math.atan2(pos.x, pos.y)); }; UnitAI.prototype.CheckTargetDistanceFromHeldPosition = function(target, iid, type) @@ -4555,9 +4745,10 @@ * Returns whether we should chase the targeted entity, * given our current stance. */ -UnitAI.prototype.ShouldChaseTargetedEntity = function(target, force) +UnitAI.prototype.ShouldChaseTarget = function(target, force) { - if (this.IsTurret()) + // When attacking ground or are turret, we can't chase. + if (target instanceof Vector3D || this.IsTurret()) return false; if (this.GetStance().respondChase) @@ -4627,6 +4818,40 @@ 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 (target instanceof Vector3D) + return target; + + let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); + if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) + return undefined; + + return cmpTargetPosition.GetPosition(); +}; + +/* + * 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 (target instanceof Vector3D) + return Vector2D.from3D(target); + + let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); + if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) + return undefined; + + return cmpTargetPosition.GetPosition2D(); +}; + UnitAI.prototype.GetTargetPositions = function() { var targetPositions = []; @@ -4644,6 +4869,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)); + break; + case "WalkToTarget": case "WalkToTargetRange": // This doesn't move to the target (just into range), but a later order will. case "Guard": @@ -4910,6 +5139,19 @@ }; /** + * 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) 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 @@ -202,6 +202,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.radius, 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 @@ -24,6 +24,7 @@ Human +