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 +attackgroundradius.decrease = PgDn ; Decrease attack ground bombard radius. +attackgroundradius.increase = PgUp ; Increase attack ground bombard radius. [hotkey.session.gui] toggle = "Alt+G" ; Toggle visibility of session GUI @@ -377,6 +379,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) +attackgroundradius = 2.0 ; Default radius of the area to bombard with attack ground (meter). +attackgroundradiuschange = 0.5 ; Value to add/subtract from the radius with each push of the hotkey (meter). [gui.session.minimap] blinkduration = 1.7 ; The blink duration while pinging Index: binaries/data/mods/public/gui/options/options.json =================================================================== --- binaries/data/mods/public/gui/options/options.json +++ binaries/data/mods/public/gui/options/options.json @@ -434,6 +434,23 @@ "max": 30 }, { + "type": "number", + "label": "Attack-Ground Bombard Size", + "tooltip": "The default value of the bombard radius (meter).", + "config": "gui.session.attackgroundradius", + "callback": "updateDefaultAttackGroundSize", + "min": 1, + "max": 20 + }, + { + "type": "slider", + "label": "Attack-Ground Increment value", + "tooltip": "Value to add/subtract from the radius with each push of the hotkey (meter).", + "config": "gui.session.attackgroundradiuschange", + "min": 0.1, + "max": 10 + }, + { "type": "boolean", "label": "Chat Notification Attack", "tooltip": "Show a chat notification if you are attacked by another player.", 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; @@ -881,9 +882,13 @@ case "mousebuttondown": if (ev.button == SDL_BUTTON_LEFT && preSelectedAction != ACTION_NONE) { - var action = determineAction(ev.x, ev.y); + let 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; @@ -897,7 +902,17 @@ inputState = INPUT_NORMAL; break; } - // else + + case "hotkeydown": + if (preSelectedAction == ACTION_ATTACKGROUND && ev.hotkey == "session.attackgroundradius.decrease") + { + AttackGroundRadiusChange(-1); + } + else if (preSelectedAction == ACTION_ATTACKGROUND && ev.hotkey == "session.attackgroundradius.increase") + { + AttackGroundRadiusChange(1); + } + default: // Slight hack: If selection is empty, reset the input state if (g_Selection.toList().length == 0) @@ -1292,6 +1307,34 @@ } } +// Attack ground: +// When the user uses the hotkey the radius of the bombarded area is increased/decreased. +var g_AttackGroundSize = getDefaultAttackGroundSize(); +function AttackGroundRadiusChange(dir) +{ + g_AttackGroundSize += dir * +Engine.ConfigDB_GetValue("user", "gui.session.attackgroundradiuschange"); + if (g_AttackGroundSize < 0 || !Number.isFinite(g_AttackGroundSize)) + g_AttackGroundSize = 0; + + updateSelectionDetails(); +} + +function getDefaultAttackGroundSize() +{ + let num = +Engine.ConfigDB_GetValue("user", "gui.session.attackgroundradius"); + 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 %(unit)s"), { + "radius": g_AttackGroundSize.toFixed(1), + "unit": translatePlural("meter", "meters", g_AttackGroundSize) + }); + + 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) @@ -1105,6 +1147,26 @@ }, }, + "attack-ground": { + "getInfo": function(entStates) + { + if (entStates.every(entState => !entState.attack || + Object.keys(entState.attack).every(type => !entState.attack[type].attackGround))) + return false; + + return { + "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.attackground") + + translate("Attack the selected ground."), + "icon": "attack-request.png" + }; + }, + "execute": function(entStates) + { + inputState = INPUT_PRESELECTEDACTION; + preSelectedAction = ACTION_ATTACKGROUND; + }, + }, + "garrison": { "getInfo": function(entStates) { @@ -1522,6 +1584,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 @@ -83,6 +83,7 @@ "0.0" + "" + "" + + "" + "" + "" + "" + @@ -144,6 +145,9 @@ "" + "" + "" + + ""+ + "" + + "" + "" + "" + "" + @@ -239,8 +243,24 @@ return []; }; +/** + * Whether the entity is able to attack ground (with the requested attack type). + * @param {string | undefined} attackType - The attack type requested. + * @return {boolean} Whether the entity is able to attack ground. + */ +Attack.prototype.CanAttackGround = function(attackType) +{ + if (attackType) + return "AttackGround" in this.template[attackType]; + + return this.GetAttackTypes().some(type => "AttackGround" in this.template[type]); +}; + Attack.prototype.CanAttack = function(target, wantedTypes) { + if (target instanceof Vector3D) + return this.CanAttackGround(); + let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) return true; @@ -355,6 +375,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) { @@ -439,9 +463,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) { @@ -464,16 +490,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) * @@ -483,11 +516,11 @@ let offsetX = randNorm[0] * distanceModifiedSpread; let offsetZ = randNorm[1] * distanceModifiedSpread; - let realTargetPosition = new Vector3D(predictedPosition.x + offsetX, targetPosition.y, predictedPosition.z + offsetZ); + let realTargetPosition = new Vector3D(predictedPosition.x + offsetX, predictedPosition.y, predictedPosition.z + offsetZ); // Recalculate when the missile will hit the target position. let realHorizDistance = realTargetPosition.horizDistanceTo(selfPosition); - timeToTarget = realHorizDistance / horizSpeed; + let timeToTarget = realHorizDistance / horizSpeed; let missileDirection = Vector3D.sub(realTargetPosition, selfPosition).div(realHorizDistance); Index: binaries/data/mods/public/simulation/components/DelayedDamage.js =================================================================== --- binaries/data/mods/public/simulation/components/DelayedDamage.js +++ binaries/data/mods/public/simulation/components/DelayedDamage.js @@ -12,7 +12,7 @@ * @param {Object} data - The data sent by the caller. * @param {string} data.type - The type of damage. * @param {Object} data.attackData - Data of the form { 'effectType': { ...opaque effect data... }, 'Bonuses': {...} }. - * @param {number} data.target - The entity id of the target. + * @param {number | Vector3D} data.target - Either the target entity ID, or a Vector3D of a position to attack. * @param {number} data.attacker - The entity id of the attacker. * @param {number} data.attackerOwner - The player id of the owner of the attacker. * @param {Vector2D} data.origin - The origin of the projectile hit. @@ -53,21 +53,27 @@ let cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager); - // Deal direct damage if we hit the main target - // and if the target has Resistance (not the case for a mirage for example) - if (Attacking.TestCollision(data.target, data.position, lateness)) + let targetPosition; + if (typeof data.target == "number") { - cmpProjectileManager.RemoveProjectile(data.projectileId); - - Attacking.HandleAttackEffects(data.type, data.attackData, data.target, data.attacker, data.attackerOwner); - return; + // Deal direct damage if we hit the main target + // and if the target has Resistance (not the case for a mirage for example) + if (Attacking.TestCollision(data.target, data.position, lateness)) + { + cmpProjectileManager.RemoveProjectile(data.projectileId); + + Attacking.HandleAttackEffects(data.type, data.attackData, data.target, data.attacker, data.attackerOwner); + return; + } + targetPosition = Attacking.InterpolatedLocation(data.target, lateness); } + else + targetPosition = data.target; - let targetPosition = Attacking.InterpolatedLocation(data.target, lateness); if (!targetPosition) return; - // If we didn't hit the main target look for nearby units + // Look for nearby units. let cmpPlayer = QueryPlayerIDInterface(data.attackerOwner); let ents = Attacking.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 @@ -454,6 +454,8 @@ // not in world, set a default? ret.attack[type].elevationAdaptedRange = ret.attack.maxRange; } + + ret.attack[type].attackGround = cmpAttack.CanAttackGround(type); } } Index: binaries/data/mods/public/simulation/components/UnitAI.js =================================================================== --- binaries/data/mods/public/simulation/components/UnitAI.js +++ binaries/data/mods/public/simulation/components/UnitAI.js @@ -427,7 +427,7 @@ if (this.IsAnimal()) this.SetNextState("ANIMAL.COMBAT.ATTACKING"); else - this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING"); + this.SetNextState("INDIVIDUAL.COMBAT"); return; } @@ -454,6 +454,27 @@ this.SetNextState("INDIVIDUAL.COMBAT.APPROACHING"); }, + "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) { if (this.IsAnimal() || this.IsTurret()) { @@ -723,15 +744,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, target)) { if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { @@ -748,6 +769,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)) { @@ -1121,7 +1159,7 @@ "COMBAT": { "APPROACHING": { "enter": function() { - if (!this.MoveTo(this.order.data)) + if (!this.MoveFormationToTargetAttackRange(this.order.data.target)) { this.FinishOrder(); return true; @@ -1136,8 +1174,13 @@ }, "MovementUpdate": function(msg) { + let target = this.order.data.target; let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); - this.CallMemberFunction("Attack", [this.order.data.target, this.order.data.allowCapture, false]); + if (typeof target == "number") + this.CallMemberFunction("Attack", [target, this.order.data.allowCapture, false]); + else + this.CallMemberFunction("AttackGround", [target, this.order.data.radius, false]); + if (cmpAttack.CanAttackAsFormation()) this.SetNextState("COMBAT.ATTACKING"); else @@ -1148,23 +1191,37 @@ "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 }); + + if (attackEntity) + this.PushOrderFront("Attack", { + "target": target, + "force": false, + "allowCapture": allowCapture + }); + else + 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); - // TODO fix the rearranging while attacking as 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); this.StartTimer(200, 200); @@ -1172,15 +1229,28 @@ }, "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 }); + if (attackEntity) + this.PushOrderFront("Attack", { + "target": target, + "force": false, + "allowCapture": allowCapture + }); + else + this.PushOrderFront("AttackGround", { + "target": target, + "radius": this.order.data.radius, + "force": false + }); return; } this.FinishOrder(); @@ -1190,7 +1260,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); }, @@ -1757,6 +1827,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 }; @@ -1780,7 +1879,9 @@ // Show weapons rather than carried resources. this.SetAnimationVariant("combat"); - 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() { @@ -1850,13 +1951,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; + let cmpFormation = Engine.QueryInterface(target, IID_Formation); + // if the target is a formation, save the attacking formation, and pick a member + if (cmpFormation) + { + this.order.data.formationTarget = target; + target = cmpFormation.GetClosestMember(this.entity); + this.order.data.target = target; + } } if (!this.CanAttack(target)) @@ -1908,6 +2013,11 @@ this.FaceTowardsTarget(this.order.data.target); let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); + if (cmpBuildingAI && !attackEntity) + { + this.FinishOrder(); + return true; + } if (cmpBuildingAI) cmpBuildingAI.SetUnitAITarget(this.order.data.target); }, @@ -1923,6 +2033,7 @@ "Timer": function(msg) { let target = this.order.data.target; + let attackEntity = typeof target == "number"; // Check the target is still alive and attackable if (!this.CanAttack(target)) @@ -1970,6 +2081,11 @@ this.SetNextState("COMBAT.CHASING"); return; } + else if (this.MoveToTargetAttackRange(target, this.order.data.attackType)) + { + this.SetNextState("COMBAT.APPROACHING"); + return; + } this.SetNextState("FINDINGNEWTARGET"); }, @@ -4288,28 +4404,45 @@ return cmpUnitMotion.MoveToTargetRange(target, 0, 1); }; +/** + * Move the entity so we hope the target is in range. + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @param {number} iid - The interface ID to check the range for. + * @param {string} type - The type for which the range is to be checked. + * @return {boolean} - Whether the order to move has succeeded. + */ UnitAI.prototype.MoveToTargetRange = function(target, iid, type) { + 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); + if (attackEntity) + return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); + return 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) { - // for formation members, the formation will take care of the range check + 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); @@ -4317,9 +4450,12 @@ return false; } - let 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); @@ -4330,22 +4466,23 @@ let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); let range = cmpAttack.GetRange(type); - let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); - if (!thisCmpPosition.IsInWorld()) + let cmpSelfPosition = Engine.QueryInterface(this.entity, IID_Position); + if (!cmpSelfPosition.IsInWorld()) return false; - let s = thisCmpPosition.GetPosition(); + let selfPosition = cmpSelfPosition.GetPosition(); - let targetCmpPosition = Engine.QueryInterface(target, IID_Position); - if (!targetCmpPosition.IsInWorld()) + let targetPosition = this.GetTargetPosition3D(target); + if (!targetPosition) return false; - let t = targetCmpPosition.GetPosition(); - // h is positive when I'm higher than the target - let h = s.y - t.y + range.elevationBonus; + // h Is positive when I'm higher than the target. + let h = selfPosition.y - targetPosition.y + range.elevationBonus; - let parabolicMaxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h); - // No negative roots please - if (h <= -range.max / 2) + // 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? parabolicMaxRange = 0; @@ -4353,7 +4490,10 @@ let guessedMaxRange = parabolicMaxRange > range.max ? (range.max + parabolicMaxRange) / 2 : parabolicMaxRange; let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); - return cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange); + if (attackEntity) + return cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange); + + return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, guessedMaxRange); }; UnitAI.prototype.MoveToTargetRangeExplicit = function(target, min, max) @@ -4365,6 +4505,50 @@ 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) + { + if (attackEntity) + return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); + else + return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, range.max, true); + } + + return false; +}; + UnitAI.prototype.MoveToGarrisonRange = function(target) { if (!this.CheckTargetVisible(target)) @@ -4407,26 +4591,44 @@ 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); + if (attackEntity) + return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false); + + return cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, range.max, false); }; /** - * Check if the target is inside the attack range - * For melee attacks, this goes straigt to the regular range calculation + * Check if the target is inside the attack range. + * For melee attacks, this goes straight to the regular range calculation. * For ranged attacks, the parabolic formula is used to accout for bigger ranges - * when the target is lower, and smaller ranges when the target is higher + * when the target is lower, and smaller ranges when the target is higher. + * + * @param {number | Vector3D} target - Either the target entity ID, or a Vector3D of a position to attack. + * @param {string} type - The type of the attack which is to be used. + * @return {boolean} - Whether the attack-location is within attacking distance. */ UnitAI.prototype.CheckTargetAttackRange = function(target, type) { - // for formation members, the formation will take care of the range check + 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); @@ -4435,36 +4637,40 @@ return true; } - let 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.CheckTargetRange(target, IID_Attack, type); - let targetCmpPosition = Engine.QueryInterface(target, IID_Position); - if (!targetCmpPosition || !targetCmpPosition.IsInWorld()) + let targetPosition = this.GetTargetPosition3D(target); + if (!targetPosition) return false; let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); let range = cmpAttack.GetRange(type); - let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); - if (!thisCmpPosition.IsInWorld()) + let cmpSelfPosition = Engine.QueryInterface(this.entity, IID_Position); + if (!cmpSelfPosition || !cmpSelfPosition.IsInWorld()) return false; - let s = thisCmpPosition.GetPosition(); + let selfPosition = cmpSelfPosition.GetPosition(); - let t = targetCmpPosition.GetPosition(); - - let h = s.y - t.y + range.elevationBonus; + let h = selfPosition.y - targetPosition.y + range.elevationBonus; let maxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h); if (maxRange < 0) return false; let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); - return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, maxRange, false); + if (attackEntity) + return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, maxRange, false); + + return cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, maxRange, false); }; UnitAI.prototype.CheckTargetRangeExplicit = function(target, min, max) @@ -4473,6 +4679,43 @@ 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); + if (attackEntity) + return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false); + + return cmpObstructionManager.IsInPointRange(this.entity, target.x, target.z, range.min, range.max, false); +}; + UnitAI.prototype.CheckGarrisonRange = function(target) { var cmpGarrisonHolder = Engine.QueryInterface(target, IID_GarrisonHolder); @@ -4490,19 +4733,25 @@ /** * Returns true if the target entity is visible through the FoW/SoD. + * @param {number | Vector3D} target - Either an entity ID, or a Vector3D of a position. + * @return {boolean} Whether the target is visible. */ UnitAI.prototype.CheckTargetVisible = function(target) { - var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); + // Assume an attackground target is either visible or not important whether it is. + if (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); + // Entities that are hidden and miraged are considered visible. + let cmpFogging = Engine.QueryInterface(target, IID_Fogging); if (cmpFogging && cmpFogging.IsMiraged(cmpOwnership.GetOwner())) return true; @@ -4555,12 +4804,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) @@ -4737,7 +4984,7 @@ */ UnitAI.prototype.ShouldChaseTargetedEntity = function(target, force) { - if (this.IsTurret()) + if (target instanceof Vector3D || this.IsTurret()) return false; if (this.GetStance().respondChase) @@ -4807,6 +5054,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 = []; @@ -4824,6 +5108,10 @@ targetPositions.push(new Vector2D(order.data.x, order.data.z)); break; // and continue the loop + case "AttackGround": + targetPositions.push(Vector2D.from3D(order.data.target)); + return targetPositions; + case "WalkToTarget": case "WalkToTargetRange": // This doesn't move to the target (just into range), but a later order will. case "Guard": @@ -5099,6 +5387,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) @@ -5707,9 +6012,9 @@ { if (!orderData) orderData = this.order.data; - let cmpPosition = Engine.QueryInterface(orderData.target, IID_Position); - if (cmpPosition && cmpPosition.IsInWorld()) - orderData.lastPos = cmpPosition.GetPosition(); + let targetPosition = this.GetTargetPosition3D(orderData.target); + if (targetPosition) + orderData.lastPos = targetPosition; }; UnitAI.prototype.SetHeldPosition = function(x, z) 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": { "Capture": 8, @@ -152,6 +153,10 @@ TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetFullAttackRange(), { "min": 0, "max": 80 }); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Capture"), { "Capture": 8 }); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.CanAttackGround("Capture"), false); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.CanAttackGround("Ranged"), true); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.CanAttackGround(), true); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Ranged"), { "Damage": { "Hack": 0, Index: binaries/data/mods/public/simulation/helpers/Commands.js =================================================================== --- binaries/data/mods/public/simulation/helpers/Commands.js +++ binaries/data/mods/public/simulation/helpers/Commands.js @@ -192,6 +192,21 @@ }); }, + "attack-ground": function(player, cmd, data) + { + let entities = []; + let target = new Vector3D(cmd.target.x, cmd.target.y, cmd.target.z); + for (let ent of data.entities) + { + let cmpAttack = Engine.QueryInterface(ent, IID_Attack); + if (cmpAttack && cmpAttack.CanAttackGround()) + entities.push(ent); + } + GetFormationUnitAIs(entities, player).forEach(cmpUnitAI => { + cmpUnitAI.AttackGround(target, cmd.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 +