function Attack() {} var g_AttackTypes = ["Melee", "Ranged", "Capture"]; Attack.prototype.preferredClassesSchema = "" + "" + "" + "tokens" + "" + "" + "" + ""; Attack.prototype.restrictedClassesSchema = "" + "" + "" + "tokens" + "" + "" + "" + ""; Attack.prototype.Schema = "Controls the attack abilities and strengths of the unit." + "" + "" + "Spear" + "" + "10.0" + "0.0" + "5.0" + "" + "4.0" + "1000" + "" + "" + "achae" + "Infantry" + "1.5" + "" + "" + "Cavalry Melee" + "1.5" + "" + "" + "Champion" + "Cavalry Infantry" + "" + "" + "Bow" + "" + "0.0" + "10.0" + "0.0" + "" + "44.0" + "20.0" + "" + "0" + "10.0" + "0" + "" + "800" + "1600" + "1000" + "" + "" + "Cavalry" + "2" + "" + "" + "" + "50.0" + "2.5" + "props/units/weapons/rock_flaming.xml" + "props/units/weapons/rock_explosion.xml" + "0.1" + "false" + "" + "Champion" + "" + "Circular" + "20" + "false" + "" + "0.0" + "10.0" + "0.0" + "" + "" + "" + "" + "" + "1000.0" + "0.0" + "0.0" + "" + "1000" + "4.0" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + AttackHelper.BuildAttackEffectsSchema() + "" + "" + "" + "" + ""+ "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + // TODO: it shouldn't be stretched "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + AttackHelper.BuildAttackEffectsSchema() + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + Attack.prototype.preferredClassesSchema + Attack.prototype.restrictedClassesSchema + "" + "" + ""; Attack.prototype.Init = function() { }; Attack.prototype.GetAttackTypes = function(wantedTypes) { const types = g_AttackTypes.filter(type => !!this.template[type]); if (!wantedTypes) return types; const wantedTypesReal = wantedTypes.filter(wtype => wtype.indexOf("!") != 0); return types.filter(type => wantedTypes.indexOf("!" + type) == -1 && (!wantedTypesReal || !wantedTypesReal.length || wantedTypesReal.indexOf(type) != -1)); }; Attack.prototype.GetProjectileCount = function(type) { if (!this.template[type] || !this.template[type].Projectile) return 1; let count = this.template[type].Projectile.Count ? +this.template[type].Projectile.Count : 1; count = ApplyValueModificationsToEntity("Attack/" + type + "/Projectile/Count", count, this.entity); return Math.max(1, Math.floor(count)); }; Attack.prototype.GetPreferredClasses = function(type) { if (this.template[type] && this.template[type].PreferredClasses && this.template[type].PreferredClasses._string) return this.template[type].PreferredClasses._string.split(/\s+/); return []; }; Attack.prototype.GetRestrictedClasses = function(type) { if (this.template[type] && this.template[type].RestrictedClasses && this.template[type].RestrictedClasses._string) return this.template[type].RestrictedClasses._string.split(/\s+/); return []; }; Attack.prototype.CanAttack = function(target, wantedTypes) { const cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) return true; const cmpThisPosition = Engine.QueryInterface(this.entity, IID_Position); const cmpTargetPosition = Engine.QueryInterface(target, IID_Position); if (!cmpThisPosition || !cmpTargetPosition || !cmpThisPosition.IsInWorld() || !cmpTargetPosition.IsInWorld()) return false; const cmpResistance = QueryMiragedInterface(target, IID_Resistance); if (!cmpResistance) return false; const cmpIdentity = QueryMiragedInterface(target, IID_Identity); if (!cmpIdentity) return false; const cmpHealth = QueryMiragedInterface(target, IID_Health); const targetClasses = cmpIdentity.GetClassesList(); if (targetClasses.indexOf("Domestic") != -1 && this.template.Slaughter && cmpHealth && cmpHealth.GetHitpoints() && (!wantedTypes || !wantedTypes.filter(wType => wType.indexOf("!") != 0).length || wantedTypes.indexOf("Slaughter") != -1)) return true; const cmpEntityPlayer = QueryOwnerInterface(this.entity); const cmpTargetPlayer = QueryOwnerInterface(target); if (!cmpTargetPlayer || !cmpEntityPlayer) return false; const types = this.GetAttackTypes(wantedTypes); const entityOwner = cmpEntityPlayer.GetPlayerID(); const targetOwner = cmpTargetPlayer.GetPlayerID(); const cmpCapturable = QueryMiragedInterface(target, IID_Capturable); const cmpDiplomacy = QueryPlayerIDInterface(entityOwner, IID_Diplomacy); // Check if the relative height difference is larger than the attack range // If the relative height is bigger, it means they will never be able to // reach each other, no matter how close they come. const heightDiff = Math.abs(cmpThisPosition.GetHeightOffset() - cmpTargetPosition.GetHeightOffset()); for (const type of types) { if (type != "Capture" && (!cmpDiplomacy?.IsEnemy(targetOwner) || !cmpHealth || !cmpHealth.GetHitpoints())) continue; if (type == "Capture" && (!cmpCapturable || !cmpCapturable.CanCapture(entityOwner))) continue; if (heightDiff > this.GetRange(type).max) continue; const restrictedClasses = this.GetRestrictedClasses(type); if (!restrictedClasses.length) return true; if (!MatchesClassList(targetClasses, restrictedClasses)) return true; } return false; }; /** * Returns undefined if we have no preference or the lowest index of a preferred class. */ Attack.prototype.GetPreference = function(target) { const cmpIdentity = Engine.QueryInterface(target, IID_Identity); if (!cmpIdentity) return undefined; const targetClasses = cmpIdentity.GetClassesList(); let minPref; for (const type of this.GetAttackTypes()) { const preferredClasses = this.GetPreferredClasses(type); for (let pref = 0; pref < preferredClasses.length; ++pref) { if (MatchesClassList(targetClasses, preferredClasses[pref])) { if (pref === 0) return pref; if ((minPref === undefined || minPref > pref)) minPref = pref; } } } return minPref; }; /** * Get the full range of attack using all available attack types. */ Attack.prototype.GetFullAttackRange = function() { const ret = { "min": Infinity, "max": 0 }; for (const type of this.GetAttackTypes()) { const range = this.GetRange(type); ret.min = Math.min(ret.min, range.min); ret.max = Math.max(ret.max, range.max); } return ret; }; Attack.prototype.GetAttackEffectsData = function(type, splash) { let template = this.template[type]; if (!template) return undefined; if (splash) template = template.Splash; return AttackHelper.GetAttackEffectsData("Attack/" + type + (splash ? "/Splash" : ""), template, this.entity); }; /** * Find the best attack against a target. * @param {number} target - The entity-ID of the target. * @param {boolean} allowCapture - Whether capturing is allowed. * @return {string} - The preferred attack type. */ Attack.prototype.GetBestAttackAgainst = function(target, allowCapture) { const types = this.GetAttackTypes(); if (Engine.QueryInterface(target, IID_Formation)) // TODO: Formation against formation needs review return g_AttackTypes.find(attack => types.indexOf(attack) != -1); const cmpIdentity = Engine.QueryInterface(target, IID_Identity); if (!cmpIdentity) return undefined; // Always slaughter domestic animals instead of using a normal attack if (this.template.Slaughter && cmpIdentity.HasClass("Domestic")) return "Slaughter"; const targetClasses = cmpIdentity.GetClassesList(); const getPreferrence = attackType => { let pref = 0; if (MatchesClassList(targetClasses, this.GetPreferredClasses(attackType))) pref += 2; if (allowCapture ? attackType === "Capture" : attackType !== "Capture") pref++; return pref; }; return types.filter(type => this.CanAttack(target, [type])).sort((a, b) => { const prefA = getPreferrence(a); const prefB = getPreferrence(b); return (types.indexOf(a) + (prefA > 0 ? prefA + types.length : 0)) - (types.indexOf(b) + (prefB > 0 ? prefB + types.length : 0)); }).pop(); }; Attack.prototype.CompareEntitiesByPreference = function(a, b) { const aPreference = this.GetPreference(a); const bPreference = this.GetPreference(b); if (aPreference === null && bPreference === null) return 0; if (aPreference === null) return 1; if (bPreference === null) return -1; return aPreference - bPreference; }; Attack.prototype.GetAttackName = function(type) { return { "name": this.template[type].AttackName._string || this.template[type].AttackName, "context": this.template[type].AttackName["@context"] }; }; Attack.prototype.GetRepeatTime = function(type) { let repeatTime = 1000; if (this.template[type] && this.template[type].RepeatTime) repeatTime = +this.template[type].RepeatTime; return ApplyValueModificationsToEntity("Attack/" + type + "/RepeatTime", repeatTime, this.entity); }; Attack.prototype.GetTimers = function(type) { return { "prepare": ApplyValueModificationsToEntity("Attack/" + type + "/PrepareTime", +(this.template[type].PrepareTime || 0), this.entity), "repeat": this.GetRepeatTime(type) }; }; Attack.prototype.GetSplashData = function(type) { if (!this.template[type].Splash) return undefined; return { "attackData": this.GetAttackEffectsData(type, true), "friendlyFire": this.template[type].Splash.FriendlyFire == "true", "radius": ApplyValueModificationsToEntity("Attack/" + type + "/Splash/Range", +this.template[type].Splash.Range, this.entity), "shape": this.template[type].Splash.Shape, }; }; Attack.prototype.GetRange = function(type) { if (!type || !this.template[type]) return this.GetFullAttackRange(); let max = +this.template[type].MaxRange; max = ApplyValueModificationsToEntity("Attack/" + type + "/MaxRange", max, this.entity); let min = +(this.template[type].MinRange || 0); min = ApplyValueModificationsToEntity("Attack/" + type + "/MinRange", min, this.entity); return { "max": max, "min": min }; }; Attack.prototype.GetAttackYOrigin = function(type) { if (!this.template[type].Origin) return 0; return ApplyValueModificationsToEntity("Attack/" + type + "/Origin/Y", +this.template[type].Origin.Y, this.entity); }; Attack.prototype.RepeatRangeCheck = function(type) { if (!this.IsTargetInRange(this.target, type)) this.StopAttacking("OutOfRange"); }; /** * @param {number} target - The target to attack. * @param {string} type - The type of attack to use. * @param {number} callerIID - The IID to notify on specific events. * * @return {boolean} - Whether we started attacking. */ Attack.prototype.StartAttacking = function(target, type, callerIID, force) { if (this.target) this.StopAttacking(); if (!this.CanAttack(target, [type])) return false; const cmpResistance = QueryMiragedInterface(target, IID_Resistance); if (!cmpResistance || !cmpResistance.AddAttacker(this.entity)) return false; const timings = this.GetTimers(type); const cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); // If the repeat time since the last attack hasn't elapsed, // delay the action to avoid attacking too fast. let prepare = timings.prepare; if (this.lastAttacked) { const repeatLeft = this.lastAttacked + timings.repeat - cmpTimer.GetTime(); prepare = Math.max(prepare, repeatLeft); } const cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (cmpVisual) { cmpVisual.SelectAnimation("attack_" + type.toLowerCase(), false, 1.0); cmpVisual.SetAnimationSyncRepeat(timings.repeat); cmpVisual.SetAnimationSyncOffset(prepare); } // Find the number of range checks needed during repeat time. const numCheck = Math.ceil(timings.repeat / 1000); // Calculate the timing offset to be half the check repeat time. const repeatPerCheck = timings.repeat / numCheck; const offset = repeatPerCheck / 2; // Set the startpoint to be the prepare time plus any remaining time not evenly divisible by the offset. let checkStart = timings.prepare + ((prepare - timings.prepare) % offset); // Add an offset to the start time if we are not already offset. if ((prepare - checkStart) % repeatPerCheck === 0) checkStart += offset; // If using a non-default prepare time, re-sync the animation when the timer runs. this.resyncAnimation = prepare != timings.prepare; this.target = target; this.callerIID = callerIID; this.force = force; this.timer = cmpTimer.SetInterval(this.entity, IID_Attack, "Attack", prepare, timings.repeat, type); this.checkTimer = cmpTimer.SetInterval(this.entity, IID_Attack, "RepeatRangeCheck", checkStart, repeatPerCheck, type); return true; }; /** * @param {string} reason - The reason why we stopped attacking. */ Attack.prototype.StopAttacking = function(reason) { if (!this.target) return; const cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.CancelTimer(this.timer); cmpTimer.CancelTimer(this.checkTimer); delete this.timer; delete this.checkTimer; const cmpResistance = QueryMiragedInterface(this.target, IID_Resistance); if (cmpResistance) cmpResistance.RemoveAttacker(this.entity); delete this.target; const cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (cmpVisual) cmpVisual.SelectAnimation("idle", false, 1.0); // The callerIID component may start again, // replacing the callerIID, hence save that. const callerIID = this.callerIID; delete this.callerIID; if (reason && callerIID) { const component = Engine.QueryInterface(this.entity, callerIID); if (component) component.ProcessMessage(reason, null); } }; /** * Attack our target entity. * @param {string} data - The attack type to use. * @param {number} lateness - The offset of the actual call and when it was expected. */ Attack.prototype.Attack = function(type, lateness) { if (!this.CanAttack(this.target, [type])) { this.StopAttacking("TargetInvalidated"); return; } // ToDo: Enable entities to keep facing a target. Engine.QueryInterface(this.entity, IID_UnitAI)?.FaceTowardsTarget(this.target); const cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); this.lastAttacked = cmpTimer.GetTime() - lateness; // BuildingAI has its own attack routine. if (!Engine.QueryInterface(this.entity, IID_BuildingAI)) this.PerformAttack(type, this.target); if (!this.target) return; // We check the range after the attack to facilitate chasing. if (!this.IsTargetInRange(this.target, type)) { this.StopAttacking("OutOfRange"); return; } // If a low preference unit is attacked without player direction, check for higher preference. if (this.GetPreference(this.target) === undefined && !this.force) { this.StopAttacking("TargetInvalidated"); return; } if (this.resyncAnimation) { const cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (cmpVisual) { const repeat = this.GetTimers(type).repeat; cmpVisual.SetAnimationSyncRepeat(repeat); cmpVisual.SetAnimationSyncOffset(repeat); } delete this.resyncAnimation; } }; /** * Attack the target entity. 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. */ Attack.prototype.PerformAttack = function(type, target) { const cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return; const selfPosition = cmpPosition.GetPosition(); const cmpTargetPosition = Engine.QueryInterface(target, IID_Position); if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) return; const targetPosition = cmpTargetPosition.GetPosition(); const cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership) return; const attackerOwner = cmpOwnership.GetOwner(); const data = { "type": type, "attackData": this.GetAttackEffectsData(type), "splash": this.GetSplashData(type), "attacker": this.entity, "attackerOwner": attackerOwner, "target": target, }; const delay = +(this.template[type].EffectDelay || 0); if (this.template[type].Projectile) { const cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); const 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 const horizSpeed = +this.template[type].Projectile.Speed; const gravity = +this.template[type].Projectile.Gravity; // horizSpeed /= 2; gravity /= 2; // slow it down for testing // We will try to estimate the position of the target, where we can hit it. // We first estimate the time-till-hit by extrapolating linearly the movement // of the last turn. We compute the time till an arrow will intersect the target. const targetVelocity = Vector3D.sub(targetPosition, cmpTargetPosition.GetPreviousPosition()).div(turnLength); const timeToTarget = PositionHelper.PredictTimeToTarget(selfPosition, horizSpeed, targetPosition, targetVelocity); // 'Cheat' and use UnitMotion to predict the position in the near-future. // This avoids 'dancing' issues with units zigzagging over very short distances. // However, this could fail if the player gives several short move orders, so // occasionally fall back to basic interpolation. let predictedPosition = targetPosition; if (timeToTarget !== false) { // Don't predict too far in the future, but avoid threshold effects. // After 1 second, always use the 'dumb' interpolated past-motion prediction. const useUnitMotion = randBool(Math.max(0, 0.75 - timeToTarget / 1.333)); if (useUnitMotion) { const cmpTargetUnitMotion = Engine.QueryInterface(target, IID_UnitMotion); const cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI); if (cmpTargetUnitMotion && (!cmpTargetUnitAI || !cmpTargetUnitAI.IsFormationMember())) { const pos2D = cmpTargetUnitMotion.EstimateFuturePosition(timeToTarget); predictedPosition.x = pos2D.x; predictedPosition.z = pos2D.y; } else predictedPosition = Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition); } else predictedPosition = Vector3D.mult(targetVelocity, timeToTarget).add(targetPosition); } const predictedHeight = cmpTargetPosition.GetHeightAt(predictedPosition.x, predictedPosition.z); // Add inaccuracy based on spread. const distanceModifiedSpread = ApplyValueModificationsToEntity("Attack/" + type + "/Projectile/Spread", +this.template[type].Projectile.Spread, this.entity) * predictedPosition.horizDistanceTo(selfPosition) / 100; let actorName = this.template[type].Projectile.ActorName || ""; const impactActorName = this.template[type].Projectile.ImpactActorName || ""; const impactAnimationLifetime = this.template[type].Projectile.ImpactAnimationLifetime || 0; // TODO: Use unit rotation to implement x/z offsets. const deltaLaunchPoint = new Vector3D(0, +this.template[type].Projectile.LaunchPoint["@y"], 0); let launchPoint = Vector3D.add(selfPosition, deltaLaunchPoint); const 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(); const visualActorLaunchPoint = cmpVisual.GetProjectileLaunchPoint(); if (visualActorLaunchPoint.length() > 0) launchPoint = visualActorLaunchPoint; } const cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager); const cmpSound = Engine.QueryInterface(this.entity, IID_Sound); const attackImpactSound = cmpSound ? cmpSound.GetSoundGroup("attack_impact_" + type.toLowerCase()) : ""; const friendlyFire = this.template[type].Projectile.FriendlyFire == "true"; const count = Math.max(1, Math.floor( ApplyValueModificationsToEntity( "Attack/" + type + "/Projectile/Count", this.template[type].Projectile.Count ? +this.template[type].Projectile.Count : 1, this.entity ) )); for (let i = 0; i < count; ++i) { const randNorm = randomNormal2D(); const offsetX = randNorm[0] * distanceModifiedSpread; const offsetZ = randNorm[1] * distanceModifiedSpread; const projectileData = { "type": type, "attackData": this.GetAttackEffectsData(type), "splash": this.GetSplashData(type), "attacker": this.entity, "attackerOwner": attackerOwner, "target": target, "position": new Vector3D(predictedPosition.x + offsetX, predictedHeight, predictedPosition.z + offsetZ), "friendlyFire": friendlyFire, "attackImpactSound": attackImpactSound }; const realHorizDistance = projectileData.position.horizDistanceTo(selfPosition); const projectileTimeToTarget = realHorizDistance / horizSpeed; const projectileDelay = delay + projectileTimeToTarget * 1000; projectileData.direction = Vector3D.sub(projectileData.position, selfPosition).div(realHorizDistance); projectileData.projectileId = cmpProjectileManager.LaunchProjectileAtPoint(launchPoint, projectileData.position, horizSpeed, gravity, actorName, impactActorName, impactAnimationLifetime); if (projectileDelay) cmpTimer.SetTimeout(SYSTEM_ENTITY, IID_DelayedDamage, "Hit", projectileDelay, projectileData); else Engine.QueryInterface(SYSTEM_ENTITY, IID_DelayedDamage).Hit(projectileData, 0); } return; } data.position = targetPosition; data.direction = Vector3D.sub(targetPosition, selfPosition); if (delay) { const cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.SetTimeout(SYSTEM_ENTITY, IID_DelayedDamage, "Hit", delay, data); } else Engine.QueryInterface(SYSTEM_ENTITY, IID_DelayedDamage).Hit(data, 0); }; /** * @param {number} - The entity ID of the target to check. * @return {boolean} - Whether this entity is in range of its target. */ Attack.prototype.IsTargetInRange = function(target, type) { const range = this.GetRange(type); return Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager).IsInTargetParabolicRange( this.entity, target, range.min, range.max, this.GetAttackYOrigin(type), false); }; Attack.prototype.OnValueModification = function(msg) { if (msg.component != "Attack") return; const cmpUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI); if (!cmpUnitAI) return; if (this.GetAttackTypes().some(type => msg.valueNames.indexOf("Attack/" + type + "/MaxRange") != -1)) cmpUnitAI.UpdateRangeQueries(); }; Attack.prototype.GetRangeOverlays = function(type = "Ranged") { if (!this.template[type] || !this.template[type].RangeOverlay) return []; const range = this.GetRange(type); const rangeOverlays = []; for (const i in range) if ((i == "min" || i == "max") && range[i]) rangeOverlays.push({ "radius": range[i], "texture": this.template[type].RangeOverlay.LineTexture, "textureMask": this.template[type].RangeOverlay.LineTextureMask, "thickness": +this.template[type].RangeOverlay.LineThickness, }); return rangeOverlays; }; Engine.RegisterComponentType(IID_Attack, "Attack", Attack);