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 @@ -236,6 +236,42 @@ "specificness": 10, }, + "attack-ground": { + "execute": function(target, action, selection, queued) + { + Engine.PostNetworkCommand({ + "type": "attack-ground", + "entities": selection, + "target": target, + "queued": queued + }); + + DrawTargetMarker(target); + + return true; + }, + "getActionInfo": function(selection, target) + { + if (!selection.attack.Ranged) + return false; + + 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 +1131,25 @@ }, }, + "attack-ground": { + "getInfo": function(entStates) + { + if (entStates.every(entState => !entState.attack || !entState.attack.Ranged)) + 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 +1568,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 @@ -499,7 +499,7 @@ * and should only be called after GetTimers().repeat msec has passed since the last * call to PerformAttack. */ -Attack.prototype.PerformAttack = function(type, target) +Attack.prototype.PerformAttack = function(type, target, attackGround) { let attackerOwner = Engine.QueryInterface(this.entity, IID_Ownership).GetOwner(); let cmpDamage = Engine.QueryInterface(SYSTEM_ENTITY, IID_Damage); @@ -521,20 +521,29 @@ 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 targetPosition = attackGround ? new Vector3D(data.target.x, data.target.y, data.target.z) : undefined; + if (!attackGround) + { + let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); + if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) + return; + targetPosition = cmpTargetPosition.GetPosition(); + } + + let predictedPosition = targetPosition; + if (!attackGround) + { + 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; + } // Add inaccuracy based on spread. let distanceModifiedSpread = ApplyValueModificationsToEntity("Attack/Ranged/Spread", +this.template[type].Projectile.Spread, this.entity) * - predictedPosition.horizDistanceTo(selfPosition) / 100; + selfPosition.horizDistanceTo(predictedPosition) / 100; let randNorm = randomNormal2D(); let offsetX = randNorm[0] * distanceModifiedSpread; @@ -544,7 +553,7 @@ // 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); @@ -595,7 +604,9 @@ "isSplash": false, "attackerOwner": attackerOwner, "attackImpactSound": attackImpactSound, - "statusEffects": this.template[type].StatusEffects + "statusEffects": this.template[type].StatusEffects, + "attackGround": attackGround, + "friendlyFire": this.template[type].FriendlyFire ? this.template[type].FriendlyFire != "false" : "false" }; if (this.template[type].Splash) { 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 @@ -94,6 +94,7 @@ * @param {Object} data.bonus - the attack bonus template from the attacker. * @param {string} data.attackImpactSound - the name of the sound emited on impact. * @param {Object} data.statusEffects - status effects eg. poisoning, burning etc. + * @param {boolean} data.attackGround - whether the missile was sent to attack-ground. * ***When splash damage*** * @param {boolean} data.friendlyFire - a flag indicating if allied entities are also damaged. * @param {number} data.radius - the radius of the splash damage. @@ -106,6 +107,8 @@ if (!data.position) return; + let friendlyFire = data.friendlyFire ? data.friendlyFire != "false" : false; + let cmpSoundManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_SoundManager); if (cmpSoundManager && data.attackImpactSound) cmpSoundManager.PlaySoundGroupAtPosition(data.attackImpactSound, data.position); @@ -121,7 +124,7 @@ "strengths": data.splashStrengths, "splashBonus": data.splashBonus, "direction": data.direction, - "playersToDamage": this.GetPlayersToDamage(data.attackerOwner, data.friendlyFire), + "playersToDamage": this.GetPlayersToDamage(data.attackerOwner, friendlyFire), "type": data.type, "attackerOwner": data.attackerOwner }); @@ -129,29 +132,33 @@ let cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager); - // Deal direct damage if we hit the main target - // and if the target has DamageReceiver (not the case for a mirage for example) - let cmpDamageReceiver = Engine.QueryInterface(data.target, IID_DamageReceiver); - if (cmpDamageReceiver && this.TestCollision(data.target, data.position, lateness)) - { - data.multiplier = GetDamageBonus(data.attacker, data.target, data.type, data.bonus); - this.CauseDamage(data); - cmpProjectileManager.RemoveProjectile(data.projectileId); + let targetPosition = data.attackGround ? new Vector3D(data.target.x, data.target.y, data.target.z) : undefined; - let cmpStatusReceiver = Engine.QueryInterface(data.target, IID_StatusEffectsReceiver); - if (cmpStatusReceiver && data.statusEffects) - cmpStatusReceiver.InflictEffects(data.statusEffects); + if (!data.attackGround) + { + // 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; + } + targetPosition = this.InterpolatedLocation(data.target, lateness); } - let targetPosition = this.InterpolatedLocation(data.target, lateness); if (!targetPosition) return; // If we didn't hit the main target look for nearby units - let cmpPlayer = QueryPlayerIDInterface(data.attackerOwner); - let ents = this.EntitiesNearPoint(Vector2D.from3D(data.position), targetPosition.horizDistanceTo(data.position) * 2, cmpPlayer.GetEnemies()); + let ents = this.EntitiesNearPoint(Vector2D.from3D(data.position), targetPosition.horizDistanceTo(data.position) * 2, this.GetPlayersToDamage(data.attackerOwner, friendlyFire)); for (let ent of ents) { if (!this.TestCollision(ent, data.position, lateness)) 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,6 +23,7 @@ FormationAttack.prototype.GetRange = function(target) { +warn("FormationAttack.js; GetRange init: " + uneval(target)); var result = {"min": 0, "max": this.canAttackAsFormation ? -1 : 0}; var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (!cmpFormation) @@ -37,7 +38,7 @@ if (!cmpAttack) continue; - var type = cmpAttack.GetBestAttackAgainst(target); + let type = target == "Ranged" ? "Ranged" : cmpAttack.GetBestAttackAgainst(target); if (!type) continue; @@ -65,6 +66,10 @@ if (result.max >= 0) result.max += extraRange; + // Happens when attackGround is requested. + if (target == "Ranged") + result.elevationBonus = 0; + return result; }; 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 @@ -444,6 +444,38 @@ this.SetNextState("INDIVIDUAL.COMBAT.APPROACHING"); }, + "Order.AttackGround": function(msg) { + // In the current system, we can only attack-ground with "Ranged" attacks. + this.order.data.attackType = "Ranged"; + + // If we are already at the target, try attacking it from here + if (this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType, true)) + { + // 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("INDIVIDUAL.ATTACKGROUND.ATTACKING"); + 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("INDIVIDUAL.ATTACKGROUND.APPROACHING"); + }, + "Order.Patrol": function(msg) { if (this.IsAnimal() || this.IsTurret()) { @@ -722,6 +754,27 @@ this.SetNextState("MEMBER"); }, + "Order.AttackGround": function(msg) { +warn("UnitAI.js; Order.AttackGround init: " + uneval(msg)); + let target = msg.data.target; + this.order.data.attackType = "Ranged"; + let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); + // Check if we are already in range, otherwise walk there + if (!this.CheckTargetAttackRange(target, this.order.data.attackType, true)) + { +warn("UnitAI.js; Order.AttackGround need to move: " + uneval(target)); + this.SetNextState("ATTACKGROUND.APPROACHING"); + return; + } +warn("UnitAI.js; Order.AttackGround no need to move: " + uneval(this.order)); + this.CallMemberFunction("AttackGround", [target, false]); +warn("UnitAI.js; Order.AttackGround no need to move: " + uneval(target)); + if (cmpAttack.CanAttackAsFormation()) + this.SetNextState("ATTACKGROUND.ATTACKING"); + else + this.SetNextState("MEMBER"); + }, + "Order.Garrison": function(msg) { if (!Engine.QueryInterface(msg.data.target, IID_GarrisonHolder)) { @@ -1158,6 +1211,76 @@ }, }, + "ATTACKGROUND": { + "APPROACHING": { + "enter": function() { +warn("UnitAI.js; FormationAttackGroundApproaching-state entered: " + uneval(this.order)); + if (!this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType, true)) + { + this.FinishOrder(); + return true; + } + let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); + cmpFormation.SetRearrange(true); + cmpFormation.MoveMembersIntoFormation(true, true); + }, + + "leave": function() { + this.StopMoving(); + }, + + "MovementUpdate": function(msg) { +warn("UnitAI.js; FormationAttackGroundApproaching-state: " + uneval(this.order)); + let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); + this.CallMemberFunction("AttackGround", [this.order.data.target, false]); + if (cmpAttack.CanAttackAsFormation()) + this.SetNextState("ATTACKGROUND.ATTACKING"); + else + this.SetNextState("MEMBER"); + }, + }, + + "ATTACKING": { + // Wait for individual members to finish + "enter": function(msg) { +warn("UnitAI.js; FormationAttackGroundAttacking-state: " + uneval(this.order)); + let target = this.order.data.target; + // Check if we are already in range, otherwise walk there + if (!this.CheckTargetAttackRange(target, this.order.data.attackType, true)) + { + this.FinishOrder(); + this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); + return true; + } + + let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); + // TODO fix the rearranging while attacking as formation + cmpFormation.SetRearrange(!this.IsAttackingAsFormation()); + cmpFormation.MoveMembersIntoFormation(false, false); + this.StartTimer(200, 200); + return false; + }, + + "Timer": function(msg) { + let target = this.order.data.target; + // Check if we are already in range, otherwise walk there + if (!this.CheckTargetAttackRange(target, this.order.data.attackType, true)) + { + this.FinishOrder(); + this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); + return; + } + }, + + "leave": function(msg) { + this.StopTimer(); + let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); + if (cmpFormation) + cmpFormation.SetRearrange(true); + }, + }, + }, + "MEMBER": { // Wait for individual members to finish "enter": function(msg) { @@ -2020,6 +2143,191 @@ }, }, + "ATTACKGROUND": { + "Order.LeaveFoundation": function(msg) { + // Ignore the order as we're busy. + return { "discardOrder": true }; + }, + + "Attacked": function(msg) { + // We're probably attacking something important, ignore anyone who's attacking us + return { "discardOrder": true }; + }, + + "APPROACHING": { + "enter": function() { + if (!this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType, true)) + { + this.FinishOrder(); + return true; + } + + // Show weapons rather than carried resources. + this.SetAnimationVariant("combat"); + + this.SelectAnimation("move"); + }, + + "leave": function() { + // Show carried resources when walking. + this.SetDefaultAnimationVariant(); + this.StopMoving(); + this.StopTimer(); + }, + + "MovementUpdate": function() { + if (!this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType, true)) + return; + // If the unit needs to unpack, do so + if (this.CanUnpack()) + { + this.PushOrderFront("Unpack", { "force": true }); + return; + } + this.SetNextState("ATTACKING"); + }, + }, + + "ATTACKING": { + "enter": function() { + let target = this.order.data.target; + // Check the target is still alive and attackable + if (!this.CheckTargetAttackRange(target, this.order.data.attackType, true)) + { + if (this.CanPack()) + { + this.PushOrderFront("Pack", { "force": true }); + return; + } + if (this.MoveToTargetAttackRange(target, this.order.data.attackType, true)) + { + this.SetNextState("ATTACKGROUND.APPROACHING"); + return true; + } + } + + this.StopMoving(); + + let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); + this.attackTimers = cmpAttack.GetTimers(this.order.data.attackType); + + // If the repeat time since the last attack hasn't elapsed, + // delay this attack to avoid attacking too fast. + let prepare = this.attackTimers.prepare; + if (this.lastAttacked) + { + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + let repeatLeft = this.lastAttacked + this.attackTimers.repeat - cmpTimer.GetTime(); + prepare = Math.max(prepare, repeatLeft); + } + + this.oldAttackType = this.order.data.attackType; + // add prefix + no capital first letter for attackType + let animationName = "attack_" + this.order.data.attackType.toLowerCase(); + if (this.IsFormationMember()) + { + let cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); + if (cmpFormation) + animationName = cmpFormation.GetFormationAnimation(this.entity, animationName); + } + this.SetAnimationVariant("combat"); + this.SelectAnimation(animationName); + this.SetAnimationSync(prepare, this.attackTimers.repeat); + this.StartTimer(prepare, this.attackTimers.repeat); + // TODO: we should probably only bother syncing projectile attacks, not melee + + // If using a non-default prepare time, re-sync the animation when the timer runs. + this.resyncAnimation = (prepare != this.attackTimers.prepare) ? true : false; + + let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); + if (cmpUnitMotion) + cmpUnitMotion.FaceTowardsPoint(target.x, target.z); + + // What to do with BuildingAI, can they attack-ground as well? + let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); + if (cmpBuildingAI) + cmpBuildingAI.SetUnitAITarget(this.order.data.target); + }, + + "leave": function() { + let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); + if (cmpBuildingAI) + cmpBuildingAI.SetUnitAITarget(0); + this.StopTimer(); + this.SetDefaultAnimationVariant(); + }, + + "Timer": function(msg) { + let target = this.order.data.target; + // Check the target is still alive and attackable + if (this.CheckTargetAttackRange(target, this.order.data.attackType, true)) + { + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + this.lastAttacked = cmpTimer.GetTime() - msg.lateness; + + let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); + if (cmpUnitMotion) + cmpUnitMotion.FaceTowardsPoint(target.x, target.z); + + // BuildingAI has it's own attack-routine + let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); + if (!cmpBuildingAI) + { + let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); + cmpAttack.PerformAttack(this.order.data.attackType, target, true); + } + + // Check we can still reach the target for the next attack + if (this.CheckTargetAttackRange(target, this.order.data.attackType, true)) + { + if (this.resyncAnimation) + { + this.SetAnimationSync(this.attackTimers.repeat, this.attackTimers.repeat); + this.resyncAnimation = false; + } + return; + } + + // Can't reach it - move to it + if (this.CanPack()) + { + this.PushOrderFront("Pack", { "force": true }); + return; + } + if (this.MoveToTargetAttackRange(target, this.order.data.attackType, true)) + { + this.SetNextState("ATTACKGROUND.APPROACHING"); + return; + } + } + + // Can't reach it - give up + if (this.FinishOrder()) + { + return; + } + + // See if we can switch to a new nearby enemy + if (this.FindNewTargets()) + { + // Attempt to immediately re-enter the timer function, to avoid wasting the attack. + // Packable units may have switched to PACKING state, thus canceling the timer and having order.data.attackType undefined. + if (this.orderQueue.length > 0 && this.orderQueue[0].data && this.orderQueue[0].data.attackType && + this.orderQueue[0].data.attackType == this.oldAttackType) + this.TimerHandler(msg.data, msg.lateness); + return; + } + }, + + // TODO: respond to target deaths immediately, rather than waiting + // until the next Timer event + + "Attacked": function(msg) { + // We're probably attacking some important location, ignore any attackers. + }, + }, + }, + "GATHER": { "APPROACHING": { "enter": function() { @@ -4161,60 +4469,75 @@ * 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 | object} target - Either the entity-ID of the entity which ought to be attacked + * or the 3D-location when attackGround is asking. + * @param {string} type - The type of the attack which is to be used. + * @param {boolean} attackGround - Whether attackGround was asked. + * @return {boolean} - Whether the order to move has succeeded? */ -UnitAI.prototype.MoveToTargetAttackRange = function(target, type) +UnitAI.prototype.MoveToTargetAttackRange = function(target, type, attackGround) { // 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()) - return false; + let targetPosition = target; + if (!attackGround) + { + let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); + if (!cmpTargetPosition.IsInWorld()) + return false; + targetPosition = cmpTargetPosition.GetPosition(); + } - 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; + let parabolicMaxRange = 0; + // No negative roots please. + 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 guessedMaxRange failes, 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)); - // if that failed, try closer - return cmpUnitMotion.MoveToTargetRange(target, 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) @@ -4284,48 +4607,61 @@ * For melee attacks, this goes straigt to the regular range calculation * For ranged attacks, the parabolic formula is used to accout for bigger ranges * when the target is lower, and smaller ranges when the target is higher + * + * @param {number | object} target - Either the entity-ID of the entity which ought to be attacked + * or the 3D-location when attackGround is asking. + * @param {string} type - The type of the attack which is to be used. + * @param {boolean} attackGround - Whether attackGround was asked. + * @return {boolean} - Whether the attack-location is within attacking distance. */ -UnitAI.prototype.CheckTargetAttackRange = function(target, type) +UnitAI.prototype.CheckTargetAttackRange = function(target, type, attackGround) { // 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()) - return false; + let targetPosition = target; + if (!attackGround) + { + let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); + if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) + return false; + targetPosition = cmpTargetPosition.GetPosition(); + } - 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 t = targetCmpPosition.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) @@ -4644,6 +4980,10 @@ targetPositions.push(new Vector2D(order.data.x, order.data.z)); break; // and continue the loop + case "AttackGround": + targetPositions.push(new Vector2D(order.data.target.x, order.data.target.z)); + break; + case "WalkToTarget": case "WalkToTargetRange": // This doesn't move to the target (just into range), but a later order will. case "Guard": @@ -4910,6 +5250,20 @@ }; /** + * Adds AttackGround order to queue, forced by the player. + * + * @param {float[]} target.x, target.y, target.z - The x,y,z-values where the units need to attack-ground + * @param {boolean} queued - Whether the order is queued or not + */ +UnitAI.prototype.AttackGround = function(target, queued) +{warn("UnitAI.js; AttackGround()"); + if (!this.CanAttackGround()) + return; + + this.AddOrder("AttackGround", { "target": target, "force": true }, queued); +}; + +/** * Adds garrison order to the queue, forced by the player. */ UnitAI.prototype.Garrison = function(target, queued) @@ -5548,6 +5902,17 @@ return cmpAttack && cmpAttack.CanAttack(target); }; +UnitAI.prototype.CanAttackGround = function() +{ + // Formation controllers should always respond to commands + // (then the individual units can make up their own minds) + if (this.IsFormationController()) + return true; + + let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); + return cmpAttack && cmpAttack.GetAttackTypes().indexOf("Ranged") != -1; +}; + UnitAI.prototype.CanGarrison = function(target) { // Formation controllers should always respond to commands 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,22 @@ }); }, + "attack-ground": function(player, cmd, data) + { + let entities = []; + for (let ent of data.entities) + { + let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); + if (cmpUnitAI.CanAttackGround()){ + entities.push(ent); +// cmpUnitAI.AttackGround(cmd.target, cmd.queued); + } + } + GetFormationUnitAIs(entities, player).forEach(cmpUnitAI => { + cmpUnitAI.AttackGround(cmd.target, cmd.queued); + }); + }, + "patrol": function(player, cmd, data) { let allowCapture = cmd.allowCapture || cmd.allowCapture == null;