Index: binaries/data/config/default.cfg =================================================================== --- binaries/data/config/default.cfg +++ binaries/data/config/default.cfg @@ -313,6 +313,8 @@ deselectgroup = Ctrl ; Modifier to deselect units when clicking group icon, instead of selecting rotate.cw = RightBracket ; Rotate building placement preview clockwise rotate.ccw = LeftBracket ; Rotate building placement preview anticlockwise +attackgroundbombardradius.decrease = PgDn ; Decrease attack ground bombard radius. +attackgroundbombardradius.increase = PgUp ; Increase attack ground bombard radius. [hotkey.session.gui] toggle = "Alt+G" ; Toggle visibility of session GUI @@ -372,6 +374,8 @@ rankabovestatusbar = true ; Show rank icons above status bars experiencestatusbar = true ; Show an experience status bar above each selected unit respoptooltipsort = 0 ; Sorting players in the resources and population tooltip by value (0 - no sort, -1 - ascending, 1 - descending) +attackgroundbombardradius = 2.0 ; Default radius of the area to bombard with attack ground (m). +attackgroundbombardchange = 0.5 ; Value to add/subtract from the radius with each push of the hotkey. [gui.session.minimap] blinkduration = 1.7 ; The blink duration while pinging 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,16 @@ inputState = INPUT_NORMAL; break; } + + case "hotkeydown": + if (preSelectedAction == ACTION_ATTACKGROUND && ev.hotkey == "session.attackgroundbombardradius.decrease") + { + AttackGroundBombardRadiusChange(-1); + } + else if (preSelectedAction == ACTION_ATTACKGROUND && ev.hotkey == "session.attackgroundbombardradius.increase") + { + AttackGroundBombardRadiusChange(1); + } // else default: // Slight hack: If selection is empty, reset the input state @@ -1322,6 +1337,34 @@ } } +// Attack ground: +// When the user uses the hotkey the radius of the bombarded area is increased/decreased. +var g_AttackGroundSize = getDefaultAttackGroundSize(); +function AttackGroundBombardRadiusChange(dir) +{ + g_AttackGroundSize += dir * +Engine.ConfigDB_GetValue("user", "gui.session.attackgroundbombardchange"); + if (g_AttackGroundSize < 0 || !Number.isFinite(g_AttackGroundSize)) + g_AttackGroundSize = 0; + + updateSelectionDetails(); +} + +function getDefaultAttackGroundSize() +{ + let num = +Engine.ConfigDB_GetValue("user", "gui.session.attackgroundbombardradius"); + return Number.isFinite(num) && num >= 0 ? num : 0; +} + +function getAttackGroundSize() +{ + return Math.max(g_AttackGroundSize, 0); +} + +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,48 @@ "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) + { + let tooltip = sprintf(translate("Bombard radius: %(radius)s"), { + "radius": g_AttackGroundSize.toFixed(1) + }); + + return { + "possible": true, + "tooltip": tooltip + }; + }, + "preSelectedActionCheck": function(target, selection) + { + if (preSelectedAction != ACTION_ATTACKGROUND) + return false; + + return { + "type": "attack-ground", + "cursor": "action-attack", + "tooltip": getActionInfo("attack-ground", target, selection).tooltip, + "target": target + }; + }, + "specificness": 50, + }, + + "patrol": { "execute": function(target, action, selection, queued) @@ -1095,6 +1137,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) { @@ -1511,6 +1572,11 @@ "cursor": cursor }; } + if (action == "attack-ground") + return { + "possible": true, + "tooltip": g_UnitActions[action].getActionInfo("attack-ground", selection).tooltip + } return { "possible": ["move", "attack-move", "remove-guard", "patrol"].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,9 +523,11 @@ }; /** - * 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) { @@ -530,16 +551,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 ? 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) * @@ -549,11 +577,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. @@ -129,23 +129,29 @@ 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)) + let targetPosition; + if (typeof data.target == "number") { - 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; + } + targetPosition = this.InterpolatedLocation(data.target, lateness); } + else + targetPosition = data.target; - let targetPosition = this.InterpolatedLocation(data.target, lateness); if (!targetPosition) return; 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 @@ -419,7 +419,7 @@ if (this.IsAnimal()) this.SetNextState("ANIMAL.COMBAT.ATTACKING"); else - this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING"); + this.SetNextState("INDIVIDUAL.COMBAT"); return; } @@ -443,7 +443,28 @@ if (this.IsAnimal()) this.SetNextState("ANIMAL.COMBAT.APPROACHING"); else - this.SetNextState("INDIVIDUAL.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; + + // Distribute the attacks over the area. + 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); + + this.SetNextState("INDIVIDUAL.COMBAT"); }, "Order.Patrol": function(msg) { @@ -701,15 +722,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)) { @@ -726,6 +747,23 @@ 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]); + if (cmpAttack.CanAttackAsFormation()) + this.SetNextState("COMBAT.ATTACKING"); + else + this.SetNextState("MEMBER"); + }, + "Order.Garrison": function(msg) { if (!Engine.QueryInterface(msg.data.target, IID_GarrisonHolder)) { @@ -1086,14 +1124,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() { @@ -1101,8 +1142,11 @@ }, "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]); + typeof target == "number" ? this.CallMemberFunction("Attack", [target, this.order.data.allowCapture, false]) : + this.CallMemberFunction("AttackGround", [target, this.order.data.radius, false]); + if (cmpAttack.CanAttackAsFormation()) this.SetNextState("COMBAT.ATTACKING"); else @@ -1113,39 +1157,63 @@ "ATTACKING": { // Wait for individual members to finish "enter": function(msg) { - var target = this.order.data.target; - var allowCapture = this.order.data.allowCapture; + let target = this.order.data.target; + let allowCapture = this.order.data.allowCapture; + let attackEntity = typeof target == "number"; + // 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)) + if (!attackEntity || this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { this.FinishOrder(); - this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); + attackEntity ? this.PushOrderFront("Attack", { + "target": target, + "force": false, + "allowCapture": allowCapture + }) : + this.PushOrderFront("AttackGround", { + "target": target, + "radius": this.order.data.radius, + "force": false + }); 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; + let target = this.order.data.target; + let allowCapture = this.order.data.allowCapture; + let attackEntity = typeof target == "number"; // 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)) + if (!attackEntity || this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { this.FinishOrder(); - this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); + attackEntity ? this.PushOrderFront("Attack", { + "target": target, + "force": false, + "allowCapture": allowCapture + }) : + this.PushOrderFront("AttackGround", { + "target": target, + "radius": this.order.data.radius, + "force": false + }); return; } this.FinishOrder(); @@ -1155,7 +1223,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); }, @@ -1726,6 +1794,35 @@ }, "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; + } + + // 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 }; @@ -1750,7 +1847,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 (typeof this.order.data.target == "number") + this.StartTimer(1000, 1000); }, "leave": function() { @@ -1811,13 +1911,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 attackEntity = typeof target == "number"; + if (attackEntity) { - this.order.data.formationTarget = target; - target = cmpFormation.GetClosestMember(this.entity); - this.order.data.target = target; + // If the target is a formation, save the attacking formation, and pick a member. + let cmpFormation = Engine.QueryInterface(target, IID_Formation); + if (cmpFormation) + { + this.order.data.formationTarget = target; + target = cmpFormation.GetClosestMember(this.entity); + this.order.data.target = target; + } } if (!this.CanAttack(target)) @@ -1876,6 +1980,12 @@ this.FaceTowardsTarget(this.order.data.target); let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); + // Attack ground with BuildingAI not yet implemented. + if (cmpBuildingAI && !attackEntity) + { + this.FinishOrder(); + return true; + } if (cmpBuildingAI) cmpBuildingAI.SetUnitAITarget(this.order.data.target); }, @@ -1890,18 +2000,22 @@ "Timer": function(msg) { let target = this.order.data.target; - let cmpFormation = Engine.QueryInterface(target, IID_Formation); + let attackEntity = typeof target == "number"; - // if the target is a formation, save the attacking formation, and pick a member - if (cmpFormation) + if (attackEntity) { - 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; + // If the target is a formation, save the attacking formation, and pick a member. + let cmpFormation = Engine.QueryInterface(target, IID_Formation); + 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 @@ -1938,7 +2052,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()) { @@ -1948,6 +2062,11 @@ this.SetNextState("COMBAT.CHASING"); return; } + else if (this.MoveToTargetAttackRange(target, this.order.data.attackType)) + { + this.SetNextState("COMBAT.APPROACHING"); + return; + } }, // TODO: respond to target deaths immediately, rather than waiting @@ -2411,7 +2530,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()) { @@ -4170,39 +4289,56 @@ var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.MoveToTargetRange(target, 0, 0); }; - +/** + * 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) { + let attackEntity = typeof target == "number"; + 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 attackEntity ? cmpUnitMotion.MoveToTargetRange(target, range.min, range.max) : + cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, range.max, true); }; /** - * 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) { + let attackEntity = typeof target == "number"; // 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 (attackEntity) + { + let cmpFormation = Engine.QueryInterface(target, IID_Formation); + if (cmpFormation) + target = cmpFormation.GetClosestMember(this.entity); + } if (type != "Ranged") return this.MoveToTargetRange(target, IID_Attack, type); @@ -4210,39 +4346,44 @@ if (!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); + // No negative roots please. + let parabolicMaxRange; + 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? - var parabolicMaxRange = 0; + parabolicMaxRange = 0; //return false; - // 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 (attackEntity) + return cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange) || + cmpUnitMotion.MoveToTargetRange(target, range.min, Math.min(range.max, parabolicMaxRange)); + + return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, guessedMaxRange) || + cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, Math.min(range.max, parabolicMaxRange)); }; UnitAI.prototype.MoveToTargetRangeExplicit = function(target, min, max) @@ -4254,6 +4395,45 @@ return cmpUnitMotion.MoveToTargetRange(target, min, max); }; +/** + * Move formation so we hope the target is within the attack range. + * + * @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 attackEntity = typeof target == "number"; + // 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 (attackEntity) + { + 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 attackEntity ? cmpUnitMotion.MoveToTargetRange(target, range.min, range.max) : + cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, range.max, true); + return false; +}; + UnitAI.prototype.MoveToGarrisonRange = function(target) { if (!this.CheckTargetVisible(target)) @@ -4296,64 +4476,83 @@ 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 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 attackEntity = typeof target == "number"; + 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, false); + return attackEntity ? cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false) : + 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) { + let attackEntity = typeof target == "number"; // 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 (attackEntity) + { + 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), false); + return attackEntity ? cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, Math.sqrt(maxRangeSq), false) : + cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, Math.sqrt(maxRangeSq), false); }; UnitAI.prototype.CheckTargetRangeExplicit = function(target, min, max) @@ -4362,6 +4561,41 @@ return cmpObstructionManager.IsInTargetRange(this.entity, target, min, max, false); }; +/** + * 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 attackEntity = typeof target == "number"; + // 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 (attackEntity) + { + 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 attackEntity ? cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false) : + cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, range.max, false); +}; + UnitAI.prototype.CheckGarrisonRange = function(target) { var cmpGarrisonHolder = Engine.QueryInterface(target, IID_GarrisonHolder); @@ -4382,16 +4616,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; @@ -4408,12 +4646,10 @@ */ 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) let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion) @@ -4590,7 +4826,8 @@ */ UnitAI.prototype.ShouldChaseTargetedEntity = 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) @@ -4660,6 +4897,43 @@ 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 (typeof target == "number") + { + 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 (typeof target == "number") + { + 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 = []; @@ -4677,6 +4951,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": @@ -4952,6 +5230,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) @@ -5559,9 +5854,10 @@ { 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) 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 @@ -101,7 +101,8 @@ "Multiplier": 3 } } - } + }, + "AttackGround": {} }, "Capture": { "Value": 8, @@ -152,6 +153,10 @@ TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetFullAttackRange(), { "min": 0, "max": 80 }); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackStrengths("Capture"), { "value": 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.GetAttackStrengths("Ranged"), { "Hack": 0, "Pierce": 10, 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 +