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,13 +499,13 @@ * 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 = false) { let attackerOwner = Engine.QueryInterface(this.entity, IID_Ownership).GetOwner(); let cmpDamage = Engine.QueryInterface(SYSTEM_ENTITY, IID_Damage); - // If this is a ranged attack, then launch a projectile - if (type == "Ranged") + // If this is a ranged ground attack, then launch a projectile. + if (type == "Ranged" && attackGround) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); let turnLength = cmpTimer.GetLatestTurnLength()/1000; @@ -521,6 +521,105 @@ if (!cmpPosition || !cmpPosition.IsInWorld()) return; let selfPosition = cmpPosition.GetPosition(); + + let targetPosition = target; + let predictedPosition = targetPosition; + + // Add inaccuracy based on spread. + let distanceModifiedSpread = ApplyValueModificationsToEntity("Attack/Ranged/Spread", +this.template[type].Projectile.Spread, this.entity) * + selfPosition.horizDistanceTo(predictedPosition) / 100; + + let randNorm = randomNormal2D(); + let offsetX = randNorm[0] * distanceModifiedSpread; + let offsetZ = randNorm[1] * distanceModifiedSpread; + + let realTargetPosition = new Vector3D(predictedPosition.x + offsetX, targetPosition.y, predictedPosition.z + offsetZ); + + // Recalculate when the missile will hit the target position. + let realHorizDistance = realTargetPosition.horizDistanceTo(selfPosition); + let timeToTarget = realHorizDistance / horizSpeed; + + let missileDirection = Vector3D.sub(realTargetPosition, selfPosition).div(realHorizDistance); + + // Launch the graphical projectile. + let cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager); + + let actorName = ""; + let impactActorName = ""; + let impactAnimationLifetime = 0; + + actorName = this.template[type].Projectile.ActorName || ""; + impactActorName = this.template[type].Projectile.ImpactActorName || ""; + impactAnimationLifetime = this.template[type].Projectile.ImpactAnimationLifetime || 0; + + // TODO: Use unit rotation to implement x/z offsets. + let deltaLaunchPoint = new Vector3D(0, this.template[type].Projectile.LaunchPoint["@y"], 0.0); + let launchPoint = Vector3D.add(selfPosition, deltaLaunchPoint); + + let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); + if (cmpVisual) + { + // if the projectile definition is missing from the template + // then fallback to the projectile name and launchpoint in the visual actor + if (!actorName) + actorName = cmpVisual.GetProjectileActor(); + + let visualActorLaunchPoint = cmpVisual.GetProjectileLaunchPoint(); + if (visualActorLaunchPoint.length() > 0) + launchPoint = visualActorLaunchPoint; + } + + let id = cmpProjectileManager.LaunchProjectileAtPoint(launchPoint, realTargetPosition, horizSpeed, gravity, actorName, impactActorName, impactAnimationLifetime); + + let attackImpactSound = ""; + let cmpSound = Engine.QueryInterface(this.entity, IID_Sound); + if (cmpSound) + attackImpactSound = cmpSound.GetSoundGroup("attack_impact_" + type.toLowerCase()); + + let data = { + "type": type, + "attacker": this.entity, + "target": target, + "strengths": this.GetAttackStrengths(type), + "position": realTargetPosition, + "direction": missileDirection, + "projectileId": id, + "bonus": this.GetBonusTemplate(type), + "isSplash": false, + "attackerOwner": attackerOwner, + "attackImpactSound": attackImpactSound, + "statusEffects": this.template[type].StatusEffects, + "attackGround": attackGround + }; + if (this.template[type].Splash) + { + data.friendlyFire = this.template[type].Splash.FriendlyFire != "false"; + data.radius = +this.template[type].Splash.Range; + data.shape = this.template[type].Splash.Shape; + data.isSplash = true; + data.splashStrengths = this.GetAttackStrengths(type + ".Splash"); + data.splashBonus = this.GetBonusTemplate(type + ".Splash"); + } + cmpTimer.SetTimeout(SYSTEM_ENTITY, IID_Damage, "MissileHit", timeToTarget * 1000 + +this.template[type].Delay, data); + } + // If this is a normal ranged attack, then launch a projectile + else if (type == "Ranged") + { + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + let turnLength = cmpTimer.GetLatestTurnLength()/1000; + // In the future this could be extended: + // * Obstacles like trees could reduce the probability of the target being hit + // * Obstacles like walls should block projectiles entirely + + let horizSpeed = +this.template[type].Projectile.Speed; + let gravity = +this.template[type].Projectile.Gravity; + //horizSpeed /= 2; gravity /= 2; // slow it down for testing + + let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); + if (!cmpPosition || !cmpPosition.IsInWorld()) + return; + let selfPosition = cmpPosition.GetPosition(); + let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) return; 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. @@ -129,29 +130,34 @@ let cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager); - // Deal direct damage if we hit the main target - // and if the target has DamageReceiver (not the case for a mirage for example) - let cmpDamageReceiver = Engine.QueryInterface(data.target, IID_DamageReceiver); - if (cmpDamageReceiver && this.TestCollision(data.target, data.position, lateness)) + if (!data.attackGround) { - data.multiplier = GetDamageBonus(data.attacker, data.target, data.type, data.bonus); - this.CauseDamage(data); - cmpProjectileManager.RemoveProjectile(data.projectileId); - - let cmpStatusReceiver = Engine.QueryInterface(data.target, IID_StatusEffectsReceiver); - if (cmpStatusReceiver && data.statusEffects) - cmpStatusReceiver.InflictEffects(data.statusEffects); + // Deal direct damage if we hit the main target + // and if the target has DamageReceiver (not the case for a mirage for example) + let cmpDamageReceiver = Engine.QueryInterface(data.target, IID_DamageReceiver); + if (cmpDamageReceiver && this.TestCollision(data.target, data.position, lateness)) + { + data.multiplier = GetDamageBonus(data.attacker, data.target, data.type, data.bonus); + this.CauseDamage(data); + cmpProjectileManager.RemoveProjectile(data.projectileId); + + let cmpStatusReceiver = Engine.QueryInterface(data.target, IID_StatusEffectsReceiver); + if (cmpStatusReceiver && data.statusEffects) + cmpStatusReceiver.InflictEffects(data.statusEffects); - return; + return; + } } - let targetPosition = this.InterpolatedLocation(data.target, lateness); + let targetPosition = data.target; + if (!data.attackGround) + 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), data.position.horizDistanceTo(targetPosition) * 2, cmpPlayer.GetEnemies()); for (let ent of ents) { if (!this.TestCollision(ent, data.position, lateness)) 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.CheckTargetAttackGroundRange(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("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()) { @@ -2020,6 +2052,187 @@ }, }, + "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.MoveToTargetAttackGroundRange(this.order.data.target, this.order.data.attackType)) + { + 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.CheckTargetAttackGroundRange(this.order.data.target, this.order.data.attackType)) + 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.CheckTargetAttackGroundRange(target, this.order.data.attackType)) + { + if (this.CanPack()) + { + this.PushOrderFront("Pack", { "force": true }); + return; + } + if (this.MoveToTargetAttackGroundRange(target, this.order.data.attackType)) + { + 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; + + this.FaceTowardsGroundTarget(this.order.data.target); + + // 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.CheckTargetAttackGroundRange(target, this.order.data.attackType)) + { + let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); + this.lastAttacked = cmpTimer.GetTime() - msg.lateness; + + this.FaceTowardsGroundTarget(target); + + // 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.CheckTargetAttackGroundRange(target, this.order.data.attackType)) + { + 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.MoveToTargetAttackGroundRange(target, this.order.data.attackType)) + { + 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() { @@ -4217,6 +4430,51 @@ return cmpUnitMotion.MoveToTargetRange(target, range.min, Math.min(range.max, parabolicMaxRange)); }; +/** + * Move unit so we hope the target ground is in the attack range + * for ranged attacks, the parabolic range is used + */ +UnitAI.prototype.MoveToTargetAttackGroundRange = function(target, type) +{ + // for formation members, the formation will take care of the range check + if (this.IsFormationMember()) + { + var cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); + if (cmpFormationUnitAI && cmpFormationUnitAI.IsAttackingAsFormation()) + return false; + } + + // Cannot attack ground with anyting else than ranged attack. + if (type != "Ranged") + return false; + + let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); + let range = cmpAttack.GetRange(type); + + let cmpThisPosition = Engine.QueryInterface(this.entity, IID_Position); + if (!cmpThisPosition.IsInWorld()) + return false; + let s = cmpThisPosition.GetPosition(); + + // h is positive when I'm higher than the target + let h = s.y-target.y+range.elevationBonus; + + 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 + let guessedMaxRange = (range.max + parabolicMaxRange)/2; + + let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); + if (cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, guessedMaxRange)) + return true; + + // if that failed, try closer + return cmpUnitMotion.MoveToPointRange(target.x, target.z, range.min, Math.min(range.max, parabolicMaxRange)); +}; + UnitAI.prototype.MoveToTargetRangeExplicit = function(target, min, max) { if (!this.CheckTargetVisible(target)) @@ -4328,6 +4586,45 @@ return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, Math.sqrt(maxRangeSq), true); }; +/** + * Check if the target location is inside the attack range + * 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 + */ +UnitAI.prototype.CheckTargetAttackGroundRange = function(target, type) +{ + // 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 true; + } + + // Cannot attack ground with anyting else than ranged attack. + if (type != "Ranged") + return false; + + let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); + let range = cmpAttack.GetRange(type); + + let cmpThisPosition = Engine.QueryInterface(this.entity, IID_Position); + if (!cmpThisPosition.IsInWorld()) + return false; + + let s = cmpThisPosition.GetPosition(); + let t = target; + + let h = s.y-t.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.IsInPointRange(this.entity, target.x, target.z, range.min, Math.sqrt(maxRangeSq), true); +}; + UnitAI.prototype.CheckTargetRangeExplicit = function(target, min, max) { let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); @@ -4394,6 +4691,23 @@ } }; +UnitAI.prototype.FaceTowardsGroundTarget = function(target) +{ + let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); + if (!cmpPosition || !cmpPosition.IsInWorld()) + return; + + let angle = cmpPosition.GetPosition2D().angleTo(target); + let rot = cmpPosition.GetRotation(); + let delta = (rot.y - angle + Math.PI) % (2 * Math.PI) - Math.PI; + if (Math.abs(delta) > 0.2) + { + let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); + if (cmpUnitMotion) + cmpUnitMotion.FaceTowardsPoint(target.x, target.z); + } +}; + UnitAI.prototype.CheckTargetDistanceFromHeldPosition = function(target, iid, type) { var cmpRanged = Engine.QueryInterface(this.entity, iid); @@ -4910,6 +5224,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) +{ +// 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) 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,20 @@ }); }, + "attack-ground": function(player, cmd, data) + { + let entities = []; + for (let ent of data.entities) + { + let cmpAttack = Engine.QueryInterface(ent, IID_Attack); + if (cmpAttack && cmpAttack.GetAttackTypes().indexOf("Ranged") != -1) + entities.push(ent); + } + GetFormationUnitAIs(entities, player).forEach(cmpUnitAI => { + cmpUnitAI.AttackGround(cmd.target, cmd.queued); + }); + }, + "patrol": function(player, cmd, data) { let allowCapture = cmd.allowCapture || cmd.allowCapture == null;