Index: ps/trunk/binaries/data/mods/public/simulation/components/Attack.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/Attack.js (revision 26391) +++ ps/trunk/binaries/data/mods/public/simulation/components/Attack.js (revision 26392) @@ -1,798 +1,784 @@ 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" + "" + "" + "pers" + "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) { let types = g_AttackTypes.filter(type => !!this.template[type]); if (!wantedTypes) return types; let wantedTypesReal = wantedTypes.filter(wtype => wtype.indexOf("!") != 0); return types.filter(type => wantedTypes.indexOf("!" + type) == -1 && (!wantedTypesReal || !wantedTypesReal.length || wantedTypesReal.indexOf(type) != -1)); }; 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); // 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" && (!cmpEntityPlayer.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) { let cmpIdentity = Engine.QueryInterface(target, IID_Identity); if (!cmpIdentity) return undefined; let targetClasses = cmpIdentity.GetClassesList(); let minPref; for (let type of this.GetAttackTypes()) { let 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() { let ret = { "min": Infinity, "max": 0 }; for (let type of this.GetAttackTypes()) { let 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 (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) { let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) { // TODO: Formation against formation needs review let types = this.GetAttackTypes(); return g_AttackTypes.find(attack => types.indexOf(attack) != -1); } let 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"; let types = this.GetAttackTypes().filter(type => this.CanAttack(target, [type])); // Check whether the target is capturable and prefer that when it is allowed. let captureIndex = types.indexOf("Capture"); if (captureIndex != -1) { if (allowCapture) return "Capture"; types.splice(captureIndex, 1); } let targetClasses = cmpIdentity.GetClassesList(); let isPreferred = attackType => MatchesClassList(targetClasses, this.GetPreferredClasses(attackType)); return types.sort((a, b) => (types.indexOf(a) + (isPreferred(a) ? types.length : 0)) - (types.indexOf(b) + (isPreferred(b) ? types.length : 0))).pop(); }; Attack.prototype.CompareEntitiesByPreference = function(a, b) { let aPreference = this.GetPreference(a); let 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) 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); }; /** * @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) { 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; let timings = this.GetTimers(type); let 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) { let repeatLeft = this.lastAttacked + timings.repeat - cmpTimer.GetTime(); prepare = Math.max(prepare, repeatLeft); } let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (cmpVisual) { cmpVisual.SelectAnimation("attack_" + type.toLowerCase(), false, 1.0); cmpVisual.SetAnimationSyncRepeat(timings.repeat); cmpVisual.SetAnimationSyncOffset(prepare); } // 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.timer = cmpTimer.SetInterval(this.entity, IID_Attack, "Attack", prepare, timings.repeat, type); return true; }; /** * @param {string} reason - The reason why we stopped attacking. */ Attack.prototype.StopAttacking = function(reason) { if (!this.target) return; let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.CancelTimer(this.timer); delete this.timer; const cmpResistance = QueryMiragedInterface(this.target, IID_Resistance); if (cmpResistance) cmpResistance.RemoveAttacker(this.entity); delete this.target; let 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. let callerIID = this.callerIID; delete this.callerIID; if (reason && callerIID) { let 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); let 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 (this.resyncAnimation) { let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (cmpVisual) { let 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) { 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; let targetPosition = cmpTargetPosition.GetPosition(); let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership) return; let attackerOwner = cmpOwnership.GetOwner(); let data = { "type": type, "attackData": this.GetAttackEffectsData(type), "splash": this.GetSplashData(type), "attacker": this.entity, "attackerOwner": attackerOwner, "target": target, }; let delay = +(this.template[type].EffectDelay || 0); if (this.template[type].Projectile) { 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 // 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. let targetVelocity = Vector3D.sub(targetPosition, cmpTargetPosition.GetPreviousPosition()).div(turnLength); let 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. let useUnitMotion = randBool(Math.max(0, 0.75 - timeToTarget / 1.333)); if (useUnitMotion) { let cmpTargetUnitMotion = Engine.QueryInterface(target, IID_UnitMotion); let cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI); if (cmpTargetUnitMotion && (!cmpTargetUnitAI || !cmpTargetUnitAI.IsFormationMember())) { let 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); } let predictedHeight = cmpTargetPosition.GetHeightAt(predictedPosition.x, predictedPosition.z); // Add inaccuracy based on spread. let distanceModifiedSpread = ApplyValueModificationsToEntity("Attack/" + type + "/Spread", +this.template[type].Projectile.Spread, this.entity) * predictedPosition.horizDistanceTo(selfPosition) / 100; let randNorm = randomNormal2D(); let offsetX = randNorm[0] * distanceModifiedSpread; let offsetZ = randNorm[1] * distanceModifiedSpread; data.position = new Vector3D(predictedPosition.x + offsetX, predictedHeight, predictedPosition.z + offsetZ); let realHorizDistance = data.position.horizDistanceTo(selfPosition); timeToTarget = realHorizDistance / horizSpeed; delay += timeToTarget * 1000; data.direction = Vector3D.sub(data.position, selfPosition).div(realHorizDistance); let actorName = this.template[type].Projectile.ActorName || ""; let impactActorName = this.template[type].Projectile.ImpactActorName || ""; let 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); 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 cmpProjectileManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ProjectileManager); data.projectileId = cmpProjectileManager.LaunchProjectileAtPoint(launchPoint, data.position, horizSpeed, gravity, actorName, impactActorName, impactAnimationLifetime); let cmpSound = Engine.QueryInterface(this.entity, IID_Sound); data.attackImpactSound = cmpSound ? cmpSound.GetSoundGroup("attack_impact_" + type.toLowerCase()) : ""; data.friendlyFire = this.template[type].Projectile.FriendlyFire == "true"; } else { data.position = targetPosition; data.direction = Vector3D.sub(targetPosition, selfPosition); } if (delay) { let 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) { - let range = this.GetRange(type); - if (type == "Ranged") - { - let cmpPositionTarget = Engine.QueryInterface(target, IID_Position); - if (!cmpPositionTarget || !cmpPositionTarget.IsInWorld()) - return false; - - let cmpPositionSelf = Engine.QueryInterface(this.entity, IID_Position); - if (!cmpPositionSelf || !cmpPositionSelf.IsInWorld()) - return false; - - let positionSelf = cmpPositionSelf.GetPosition(); - let positionTarget = cmpPositionTarget.GetPosition(); - - const heightDifference = positionSelf.y + this.GetAttackYOrigin(type) - positionTarget.y; - range.max = Math.sqrt(Math.square(range.max) + 2 * range.max * heightDifference); - - if (range.max < 0) - return false; - } - let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); - return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false); + 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; let 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 []; let range = this.GetRange(type); let rangeOverlays = []; for (let 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); Index: ps/trunk/binaries/data/mods/public/simulation/components/BuildingAI.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/BuildingAI.js (revision 26391) +++ ps/trunk/binaries/data/mods/public/simulation/components/BuildingAI.js (revision 26392) @@ -1,394 +1,384 @@ // Number of rounds of firing per 2 seconds. const roundCount = 10; const attackType = "Ranged"; function BuildingAI() {} BuildingAI.prototype.Schema = "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; BuildingAI.prototype.MAX_PREFERENCE_BONUS = 2; BuildingAI.prototype.Init = function() { this.currentRound = 0; this.archersGarrisoned = 0; this.arrowsLeft = 0; this.targetUnits = []; }; BuildingAI.prototype.OnGarrisonedUnitsChanged = function(msg) { let classes = this.template.GarrisonArrowClasses; for (let ent of msg.added) { let cmpIdentity = Engine.QueryInterface(ent, IID_Identity); if (cmpIdentity && MatchesClassList(cmpIdentity.GetClassesList(), classes)) ++this.archersGarrisoned; } for (let ent of msg.removed) { let cmpIdentity = Engine.QueryInterface(ent, IID_Identity); if (cmpIdentity && MatchesClassList(cmpIdentity.GetClassesList(), classes)) --this.archersGarrisoned; } }; BuildingAI.prototype.OnOwnershipChanged = function(msg) { this.targetUnits = []; this.SetupRangeQuery(); this.SetupGaiaRangeQuery(); }; BuildingAI.prototype.OnDiplomacyChanged = function(msg) { if (!IsOwnedByPlayer(msg.player, this.entity)) return; // Remove maybe now allied/neutral units. this.targetUnits = []; this.SetupRangeQuery(); this.SetupGaiaRangeQuery(); }; BuildingAI.prototype.OnDestroy = function() { if (this.timer) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.CancelTimer(this.timer); this.timer = undefined; } // Clean up range queries. let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.enemyUnitsQuery) cmpRangeManager.DestroyActiveQuery(this.enemyUnitsQuery); if (this.gaiaUnitsQuery) cmpRangeManager.DestroyActiveQuery(this.gaiaUnitsQuery); }; /** * React on Attack value modifications, as it might influence the range. */ BuildingAI.prototype.OnValueModification = function(msg) { if (msg.component != "Attack") return; this.targetUnits = []; this.SetupRangeQuery(); this.SetupGaiaRangeQuery(); }; /** * Setup the Range Query to detect units coming in & out of range. */ BuildingAI.prototype.SetupRangeQuery = function() { var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return; var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.enemyUnitsQuery) { cmpRangeManager.DestroyActiveQuery(this.enemyUnitsQuery); this.enemyUnitsQuery = undefined; } var cmpPlayer = QueryOwnerInterface(this.entity); if (!cmpPlayer) return; var enemies = cmpPlayer.GetEnemies(); // Remove gaia. if (enemies.length && enemies[0] == 0) enemies.shift(); if (!enemies.length) return; const range = cmpAttack.GetRange(attackType); const yOrigin = cmpAttack.GetAttackYOrigin(attackType); // This takes entity sizes into accounts, so no need to compensate for structure size. this.enemyUnitsQuery = cmpRangeManager.CreateActiveParabolicQuery( this.entity, range.min, range.max, yOrigin, enemies, IID_Resistance, cmpRangeManager.GetEntityFlagMask("normal")); cmpRangeManager.EnableActiveQuery(this.enemyUnitsQuery); }; // Set up a range query for Gaia units within LOS range which can be attacked. // This should be called whenever our ownership changes. BuildingAI.prototype.SetupGaiaRangeQuery = function() { var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return; var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.gaiaUnitsQuery) { cmpRangeManager.DestroyActiveQuery(this.gaiaUnitsQuery); this.gaiaUnitsQuery = undefined; } var cmpPlayer = QueryOwnerInterface(this.entity); if (!cmpPlayer || !cmpPlayer.IsEnemy(0)) return; const range = cmpAttack.GetRange(attackType); const yOrigin = cmpAttack.GetAttackYOrigin(attackType); // This query is only interested in Gaia entities that can attack. // This takes entity sizes into accounts, so no need to compensate for structure size. this.gaiaUnitsQuery = cmpRangeManager.CreateActiveParabolicQuery( this.entity, range.min, range.max, yOrigin, [0], IID_Attack, cmpRangeManager.GetEntityFlagMask("normal")); cmpRangeManager.EnableActiveQuery(this.gaiaUnitsQuery); }; /** * Called when units enter or leave range. */ BuildingAI.prototype.OnRangeUpdate = function(msg) { var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return; // Target enemy units except non-dangerous animals. if (msg.tag == this.gaiaUnitsQuery) { msg.added = msg.added.filter(e => { let cmpUnitAI = Engine.QueryInterface(e, IID_UnitAI); return cmpUnitAI && (!cmpUnitAI.IsAnimal() || cmpUnitAI.IsDangerousAnimal()); }); } else if (msg.tag != this.enemyUnitsQuery) return; // Add new targets. for (let entity of msg.added) if (cmpAttack.CanAttack(entity)) this.targetUnits.push(entity); // Remove targets outside of vision-range. for (let entity of msg.removed) { let index = this.targetUnits.indexOf(entity); if (index > -1) this.targetUnits.splice(index, 1); } if (this.targetUnits.length) this.StartTimer(); }; BuildingAI.prototype.StartTimer = function() { if (this.timer) return; var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return; var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); var attackTimers = cmpAttack.GetTimers(attackType); this.timer = cmpTimer.SetInterval(this.entity, IID_BuildingAI, "FireArrows", attackTimers.prepare, attackTimers.repeat / roundCount, null); }; BuildingAI.prototype.GetDefaultArrowCount = function() { var arrowCount = +this.template.DefaultArrowCount; return Math.round(ApplyValueModificationsToEntity("BuildingAI/DefaultArrowCount", arrowCount, this.entity)); }; BuildingAI.prototype.GetMaxArrowCount = function() { if (!this.template.MaxArrowCount) return Infinity; let maxArrowCount = +this.template.MaxArrowCount; return Math.round(ApplyValueModificationsToEntity("BuildingAI/MaxArrowCount", maxArrowCount, this.entity)); }; BuildingAI.prototype.GetGarrisonArrowMultiplier = function() { var arrowMult = +this.template.GarrisonArrowMultiplier; return ApplyValueModificationsToEntity("BuildingAI/GarrisonArrowMultiplier", arrowMult, this.entity); }; BuildingAI.prototype.GetGarrisonArrowClasses = function() { var string = this.template.GarrisonArrowClasses; if (string) return string.split(/\s+/); return []; }; /** * Returns the number of arrows which needs to be fired. * DefaultArrowCount + Garrisoned Archers (i.e., any unit capable * of shooting arrows from inside buildings). */ BuildingAI.prototype.GetArrowCount = function() { let count = this.GetDefaultArrowCount() + Math.round(this.archersGarrisoned * this.GetGarrisonArrowMultiplier()); return Math.min(count, this.GetMaxArrowCount()); }; BuildingAI.prototype.SetUnitAITarget = function(ent) { this.unitAITarget = ent; if (ent) this.StartTimer(); }; /** * Fire arrows with random temporal distribution on prefered targets. * Called 'roundCount' times every 'RepeatTime' seconds when there are units in the range. */ BuildingAI.prototype.FireArrows = function() { if (!this.targetUnits.length && !this.unitAITarget) { if (!this.timer) return; let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.CancelTimer(this.timer); this.timer = undefined; return; } let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return; if (this.currentRound > roundCount - 1) this.currentRound = 0; if (this.currentRound == 0) this.arrowsLeft = this.GetArrowCount(); let arrowsToFire = 0; if (this.currentRound == roundCount - 1) arrowsToFire = this.arrowsLeft; else arrowsToFire = Math.min( randIntInclusive(0, 2 * this.GetArrowCount() / roundCount), this.arrowsLeft ); if (arrowsToFire <= 0) { ++this.currentRound; return; } // Add targets to a weighted list, to allow preferences. let targets = new WeightedList(); let maxPreference = this.MAX_PREFERENCE_BONUS; let addTarget = function(target) { let preference = cmpAttack.GetPreference(target); let weight = 1; if (preference !== null && preference !== undefined) weight += maxPreference / (1 + preference); targets.push(target, weight); }; // Add the UnitAI target separately, as the UnitMotion and RangeManager implementations differ. if (this.unitAITarget && this.targetUnits.indexOf(this.unitAITarget) == -1) addTarget(this.unitAITarget); for (let target of this.targetUnits) addTarget(target); // The obstruction manager performs approximate range checks. // so we need to verify them here. // TODO: perhaps an optional 'precise' mode to range queries would be more performant. - let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); + const cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); const range = cmpAttack.GetRange(attackType); - - let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); - if (!thisCmpPosition.IsInWorld()) - return; - const y = thisCmpPosition.GetPosition().y + cmpAttack.GetAttackYOrigin(attackType); + const yOrigin = cmpAttack.GetAttackYOrigin(attackType); let firedArrows = 0; while (firedArrows < arrowsToFire && targets.length()) { - let selectedTarget = targets.randomItem(); - - let targetCmpPosition = Engine.QueryInterface(selectedTarget, IID_Position); - if (targetCmpPosition && targetCmpPosition.IsInWorld() && this.CheckTargetVisible(selectedTarget)) + const selectedTarget = targets.randomItem(); + if (this.CheckTargetVisible(selectedTarget) && cmpObstructionManager.IsInTargetParabolicRange( + this.entity, + selectedTarget, + range.min, + range.max, + yOrigin, + false)) { - // Parabolic range compuation is the same as in UnitAI's MoveToTargetAttackRange. - // h is positive when I'm higher than the target. - const h = y - targetCmpPosition.GetPosition().y; - if (h > -range.max / 2 && cmpObstructionManager.IsInTargetRange( - this.entity, - selectedTarget, - range.min, - Math.sqrt(Math.square(range.max) + 2 * range.max * h), false)) - { - cmpAttack.PerformAttack(attackType, selectedTarget); - PlaySound("attack_" + attackType.toLowerCase(), this.entity); - ++firedArrows; - continue; - } + cmpAttack.PerformAttack(attackType, selectedTarget); + PlaySound("attack_" + attackType.toLowerCase(), this.entity); + ++firedArrows; + continue; } // Could not attack target, try a different target. targets.remove(selectedTarget); } this.arrowsLeft -= firedArrows; ++this.currentRound; }; /** * Returns true if the target entity is visible through the FoW/SoD. */ BuildingAI.prototype.CheckTargetVisible = function(target) { var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership) return false; // Entities that are hidden and miraged are considered visible. var cmpFogging = Engine.QueryInterface(target, IID_Fogging); if (cmpFogging && cmpFogging.IsMiraged(cmpOwnership.GetOwner())) return true; // Either visible directly, or visible in fog. let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); return cmpRangeManager.GetLosVisibility(target, cmpOwnership.GetOwner()) != "hidden"; }; Engine.RegisterComponentType(IID_BuildingAI, "BuildingAI", BuildingAI); Index: ps/trunk/binaries/data/mods/public/simulation/components/UnitAI.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/UnitAI.js (revision 26391) +++ ps/trunk/binaries/data/mods/public/simulation/components/UnitAI.js (revision 26392) @@ -1,6535 +1,6518 @@ function UnitAI() {} UnitAI.prototype.Schema = "Controls the unit's movement, attacks, etc, in response to commands from the player." + "" + "" + "" + "violent" + "aggressive" + "defensive" + "passive" + "standground" + "skittish" + "passive-defensive" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "tokens" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""+ "" + ""; // Unit stances. // There some targeting options: // targetVisibleEnemies: anything in vision range is a viable target // targetAttackersAlways: anything that hurts us is a viable target, // possibly overriding user orders! // There are some response options, triggered when targets are detected: // respondFlee: run away // respondFleeOnSight: run away when an enemy is sighted // respondChase: start chasing after the enemy // respondChaseBeyondVision: start chasing, and don't stop even if it's out // of this unit's vision range (though still visible to the player) // respondStandGround: attack enemy but don't move at all // respondHoldGround: attack enemy but don't move far from current position // TODO: maybe add targetAggressiveEnemies (don't worry about lone scouts, // do worry around armies slaughtering the guy standing next to you), etc. var g_Stances = { "violent": { "targetVisibleEnemies": true, "targetAttackersAlways": true, "respondFlee": false, "respondFleeOnSight": false, "respondChase": true, "respondChaseBeyondVision": true, "respondStandGround": false, "respondHoldGround": false, "selectable": true }, "aggressive": { "targetVisibleEnemies": true, "targetAttackersAlways": false, "respondFlee": false, "respondFleeOnSight": false, "respondChase": true, "respondChaseBeyondVision": false, "respondStandGround": false, "respondHoldGround": false, "selectable": true }, "defensive": { "targetVisibleEnemies": true, "targetAttackersAlways": false, "respondFlee": false, "respondFleeOnSight": false, "respondChase": false, "respondChaseBeyondVision": false, "respondStandGround": false, "respondHoldGround": true, "selectable": true }, "passive": { "targetVisibleEnemies": false, "targetAttackersAlways": false, "respondFlee": true, "respondFleeOnSight": false, "respondChase": false, "respondChaseBeyondVision": false, "respondStandGround": false, "respondHoldGround": false, "selectable": true }, "standground": { "targetVisibleEnemies": true, "targetAttackersAlways": false, "respondFlee": false, "respondFleeOnSight": false, "respondChase": false, "respondChaseBeyondVision": false, "respondStandGround": true, "respondHoldGround": false, "selectable": true }, "skittish": { "targetVisibleEnemies": false, "targetAttackersAlways": false, "respondFlee": true, "respondFleeOnSight": true, "respondChase": false, "respondChaseBeyondVision": false, "respondStandGround": false, "respondHoldGround": false, "selectable": false }, "passive-defensive": { "targetVisibleEnemies": false, "targetAttackersAlways": false, "respondFlee": false, "respondFleeOnSight": false, "respondChase": false, "respondChaseBeyondVision": false, "respondStandGround": false, "respondHoldGround": true, "selectable": false }, "none": { // Only to be used by AI or trigger scripts "targetVisibleEnemies": false, "targetAttackersAlways": false, "respondFlee": false, "respondFleeOnSight": false, "respondChase": false, "respondChaseBeyondVision": false, "respondStandGround": false, "respondHoldGround": false, "selectable": false } }; // These orders always require a packed unit, so if a unit that is unpacking is given one of these orders, // it will immediately cancel unpacking. var g_OrdersCancelUnpacking = new Set([ "FormationWalk", "Walk", "WalkAndFight", "WalkToTarget", "Patrol", "Garrison" ]); // When leaving a foundation, we want to be clear of it by this distance. var g_LeaveFoundationRange = 4; UnitAI.prototype.notifyToCheerInRange = 30; // To reject an order, use 'return this.FinishOrder();' const ACCEPT_ORDER = true; // See ../helpers/FSM.js for some documentation of this FSM specification syntax UnitAI.prototype.UnitFsmSpec = { // Default event handlers: "MovementUpdate": function(msg) { // ignore spurious movement messages // (these can happen when stopping moving at the same time // as switching states) }, "ConstructionFinished": function(msg) { // ignore uninteresting construction messages }, "LosRangeUpdate": function(msg) { // Ignore newly-seen units by default. }, "LosHealRangeUpdate": function(msg) { // Ignore newly-seen injured units by default. }, "LosAttackRangeUpdate": function(msg) { // Ignore newly-seen enemy units by default. }, "Attacked": function(msg) { // ignore attacker }, "PackFinished": function(msg) { // ignore }, "PickupCanceled": function(msg) { // ignore }, "TradingCanceled": function(msg) { // ignore }, "GuardedAttacked": function(msg) { // ignore }, "OrderTargetRenamed": function() { // By default, trigger an exit-reenter // so that state preconditions are checked against the new entity // (there is no reason to assume the target is still valid). this.SetNextState(this.GetCurrentState()); }, // Formation handlers: "FormationLeave": function(msg) { // Overloaded by FORMATIONMEMBER // We end up here if LeaveFormation was called when the entity // was executing an order in an individual state, so we must // discard the order now that it has been executed. if (this.order && this.order.type === "LeaveFormation") this.FinishOrder(); }, // Called when being told to walk as part of a formation "Order.FormationWalk": function(msg) { if (!this.IsFormationMember() || !this.AbleToMove()) return this.FinishOrder(); if (this.CanPack()) { // If the controller is IDLE, this is just the regular reformation timer. // In that case we don't actually want to move, as that would unpack us. let cmpControllerAI = Engine.QueryInterface(this.GetFormationController(), IID_UnitAI); if (cmpControllerAI.IsIdle()) return this.FinishOrder(); this.PushOrderFront("Pack", { "force": true }); } else this.SetNextState("FORMATIONMEMBER.WALKING"); return ACCEPT_ORDER; }, // Special orders: // (these will be overridden by various states) "Order.LeaveFoundation": function(msg) { if (!this.WillMoveFromFoundation(msg.data.target)) return this.FinishOrder(); msg.data.min = g_LeaveFoundationRange; this.SetNextState("INDIVIDUAL.WALKING"); return ACCEPT_ORDER; }, // Individual orders: "Order.LeaveFormation": function() { if (!this.IsFormationMember()) return this.FinishOrder(); let cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) { cmpFormation.SetRearrange(false); // Triggers FormationLeave, which ultimately will FinishOrder, // discarding this order. cmpFormation.RemoveMembers([this.entity]); cmpFormation.SetRearrange(true); } return ACCEPT_ORDER; }, "Order.Stop": function(msg) { this.FinishOrder(); return ACCEPT_ORDER; }, "Order.Walk": function(msg) { if (!this.AbleToMove()) return this.FinishOrder(); if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return ACCEPT_ORDER; } this.SetHeldPosition(msg.data.x, msg.data.z); msg.data.relaxed = true; this.SetNextState("INDIVIDUAL.WALKING"); return ACCEPT_ORDER; }, "Order.WalkAndFight": function(msg) { if (!this.AbleToMove()) return this.FinishOrder(); if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return ACCEPT_ORDER; } this.SetHeldPosition(msg.data.x, msg.data.z); msg.data.relaxed = true; this.SetNextState("INDIVIDUAL.WALKINGANDFIGHTING"); return ACCEPT_ORDER; }, "Order.WalkToTarget": function(msg) { if (!this.AbleToMove()) return this.FinishOrder(); if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return ACCEPT_ORDER; } if (this.CheckRange(msg.data)) return this.FinishOrder(); msg.data.relaxed = true; this.SetNextState("INDIVIDUAL.WALKING"); return ACCEPT_ORDER; }, "Order.PickupUnit": function(msg) { let cmpHolder = Engine.QueryInterface(this.entity, msg.data.iid); if (!cmpHolder || cmpHolder.IsFull()) return this.FinishOrder(); let range = cmpHolder.LoadingRange(); msg.data.min = range.min; msg.data.max = range.max; if (this.CheckRange(msg.data)) return this.FinishOrder(); // Check if we need to move // If the target can reach us and we are reasonably close, don't move. // TODO: it would be slightly more optimal to check for real, not bird-flight distance. let cmpPassengerMotion = Engine.QueryInterface(msg.data.target, IID_UnitMotion); if (cmpPassengerMotion && cmpPassengerMotion.IsTargetRangeReachable(this.entity, range.min, range.max) && PositionHelper.DistanceBetweenEntities(this.entity, msg.data.target) < 200) this.SetNextState("INDIVIDUAL.PICKUP.LOADING"); else if (this.AbleToMove()) this.SetNextState("INDIVIDUAL.PICKUP.APPROACHING"); else return this.FinishOrder(); return ACCEPT_ORDER; }, "Order.Guard": function(msg) { if (!this.AddGuard(msg.data.target)) return this.FinishOrder(); if (this.CheckTargetRangeExplicit(this.isGuardOf, 0, this.guardRange)) this.SetNextState("INDIVIDUAL.GUARD.GUARDING"); else if (this.AbleToMove()) this.SetNextState("INDIVIDUAL.GUARD.ESCORTING"); else return this.FinishOrder(); return ACCEPT_ORDER; }, "Order.Flee": function(msg) { if (!this.AbleToMove()) return this.FinishOrder(); this.SetNextState("INDIVIDUAL.FLEEING"); return ACCEPT_ORDER; }, "Order.Attack": function(msg) { let type = this.GetBestAttackAgainst(msg.data.target, msg.data.allowCapture); if (!type) return this.FinishOrder(); msg.data.attackType = type; this.RememberTargetPosition(); if (msg.data.hunting && this.orderQueue.length > 1 && this.orderQueue[1].type === "Gather") this.RememberTargetPosition(this.orderQueue[1].data); if (this.CheckTargetAttackRange(msg.data.target, msg.data.attackType)) { if (this.CanUnpack()) { this.PushOrderFront("Unpack", { "force": true }); return ACCEPT_ORDER; } // Cancel any current packing order. if (this.EnsureCorrectPackStateForAttack(false)) this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING"); return ACCEPT_ORDER; } // If we're hunting, that's a special case where we should continue attacking our target. if (this.GetStance().respondStandGround && !msg.data.force && !msg.data.hunting || !this.AbleToMove()) return this.FinishOrder(); if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return ACCEPT_ORDER; } // If we're currently packing/unpacking, make sure we are packed, so we can move. if (this.EnsureCorrectPackStateForAttack(true)) this.SetNextState("INDIVIDUAL.COMBAT.APPROACHING"); return ACCEPT_ORDER; }, "Order.Patrol": function(msg) { if (!this.AbleToMove()) return this.FinishOrder(); if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return ACCEPT_ORDER; } msg.data.relaxed = true; this.SetNextState("INDIVIDUAL.PATROL.PATROLLING"); return ACCEPT_ORDER; }, "Order.Heal": function(msg) { if (!this.TargetIsAlive(msg.data.target)) return this.FinishOrder(); // Healers can't heal themselves. if (msg.data.target == this.entity) return this.FinishOrder(); if (this.CheckTargetRange(msg.data.target, IID_Heal)) { this.SetNextState("INDIVIDUAL.HEAL.HEALING"); return ACCEPT_ORDER; } if (!this.AbleToMove()) return this.FinishOrder(); if (this.GetStance().respondStandGround && !msg.data.force) return this.FinishOrder(); this.SetNextState("INDIVIDUAL.HEAL.APPROACHING"); return ACCEPT_ORDER; }, "Order.Gather": function(msg) { let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (!cmpResourceGatherer) return this.FinishOrder(); // We were given the order to gather while we were still gathering. // This is needed because we don't re-enter the GATHER-state. const taskedResourceType = cmpResourceGatherer.GetTaskedResourceType(); if (taskedResourceType && msg.data.type.generic != taskedResourceType) this.UnitFsm.SwitchToNextState(this, "INDIVIDUAL.GATHER"); if (!this.CanGather(msg.data.target)) { this.SetNextState("INDIVIDUAL.GATHER.FINDINGNEWTARGET"); return ACCEPT_ORDER; } if (this.MustKillGatherTarget(msg.data.target)) { const bestAttack = Engine.QueryInterface(this.entity, IID_Attack)?.GetBestAttackAgainst(msg.data.target, false); // Make sure we can attack the target, else we'll get very stuck if (!bestAttack) { // Oops, we can't attack at all - give up // TODO: should do something so the player knows why this failed return this.FinishOrder(); } // The target was visible when this order was issued, // but could now be invisible again. if (!this.CheckTargetVisible(msg.data.target)) { if (msg.data.secondTry === undefined) { msg.data.secondTry = true; this.PushOrderFront("Walk", msg.data.lastPos); } // We couldn't move there, or the target moved away else if (!this.FinishOrder()) this.PushOrderFront("GatherNearPosition", { "x": msg.data.lastPos.x, "z": msg.data.lastPos.z, "type": msg.data.type, "template": msg.data.template }); return ACCEPT_ORDER; } if (!this.AbleToMove() && !this.CheckTargetRange(msg.data.target, IID_Attack, bestAttack)) return this.FinishOrder(); this.PushOrderFront("Attack", { "target": msg.data.target, "force": !!msg.data.force, "hunting": true, "allowCapture": false }); return ACCEPT_ORDER; } // If the unit is full go to the nearest dropsite instead of trying to gather. if (!cmpResourceGatherer.CanCarryMore(msg.data.type.generic)) { this.SetNextState("INDIVIDUAL.GATHER.RETURNINGRESOURCE"); return ACCEPT_ORDER; } this.RememberTargetPosition(); if (!msg.data.initPos) msg.data.initPos = msg.data.lastPos; if (this.CheckTargetRange(msg.data.target, IID_ResourceGatherer)) this.SetNextState("INDIVIDUAL.GATHER.GATHERING"); else if (this.AbleToMove()) this.SetNextState("INDIVIDUAL.GATHER.APPROACHING"); else return this.FinishOrder(); return ACCEPT_ORDER; }, "Order.GatherNearPosition": function(msg) { if (!this.AbleToMove()) return this.FinishOrder(); this.SetNextState("INDIVIDUAL.GATHER.WALKING"); msg.data.initPos = { 'x': msg.data.x, 'z': msg.data.z }; msg.data.relaxed = true; return ACCEPT_ORDER; }, "Order.DropAtNearestDropSite": function(msg) { const cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (!cmpResourceGatherer) return this.FinishOrder(); const nearby = this.FindNearestDropsite(cmpResourceGatherer.GetMainCarryingType()); if (!nearby) return this.FinishOrder(); this.ReturnResource(nearby, false, true); return ACCEPT_ORDER; }, "Order.ReturnResource": function(msg) { if (this.CheckTargetRange(msg.data.target, IID_ResourceGatherer)) this.SetNextState("INDIVIDUAL.RETURNRESOURCE.DROPPINGRESOURCES"); else if (this.AbleToMove()) this.SetNextState("INDIVIDUAL.RETURNRESOURCE.APPROACHING"); else return this.FinishOrder(); return ACCEPT_ORDER; }, "Order.Trade": function(msg) { if (!this.AbleToMove()) return this.FinishOrder(); // We must check if this trader has both markets in case it was a back-to-work order. let cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); if (!cmpTrader || !cmpTrader.HasBothMarkets()) return this.FinishOrder(); this.waypoints = []; this.SetNextState("TRADE.APPROACHINGMARKET"); return ACCEPT_ORDER; }, "Order.Repair": function(msg) { if (this.CheckTargetRange(msg.data.target, IID_Builder)) this.SetNextState("INDIVIDUAL.REPAIR.REPAIRING"); else if (this.AbleToMove()) this.SetNextState("INDIVIDUAL.REPAIR.APPROACHING"); else return this.FinishOrder(); return ACCEPT_ORDER; }, "Order.Garrison": function(msg) { if (!this.AbleToMove()) return this.FinishOrder(); // Also pack when we are in range. if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return ACCEPT_ORDER; } if (this.CheckTargetRange(msg.data.target, msg.data.garrison ? IID_Garrisonable : IID_Turretable)) this.SetNextState("INDIVIDUAL.GARRISON.GARRISONING"); else this.SetNextState("INDIVIDUAL.GARRISON.APPROACHING"); return ACCEPT_ORDER; }, "Order.Ungarrison": function(msg) { // Note that this order MUST succeed, or we break // the assumptions done in garrisonable/garrisonHolder, // especially in Unloading in the latter. (For user feedback.) // ToDo: This can be fixed by not making that assumption :) this.FinishOrder(); return ACCEPT_ORDER; }, "Order.Cheer": function(msg) { return this.FinishOrder(); }, "Order.Pack": function(msg) { if (!this.CanPack()) return this.FinishOrder(); this.SetNextState("INDIVIDUAL.PACKING"); return ACCEPT_ORDER; }, "Order.Unpack": function(msg) { if (!this.CanUnpack()) return this.FinishOrder(); this.SetNextState("INDIVIDUAL.UNPACKING"); return ACCEPT_ORDER; }, "Order.MoveToChasingPoint": function(msg) { // Overriden by the CHASING state. // Can however happen outside of it when renaming... // TODO: don't use an order for that behaviour. return this.FinishOrder(); }, "Order.CollectTreasure": function(msg) { if (this.CheckTargetRange(msg.data.target, IID_TreasureCollector)) this.SetNextState("INDIVIDUAL.COLLECTTREASURE.COLLECTING"); else if (this.AbleToMove()) this.SetNextState("INDIVIDUAL.COLLECTTREASURE.APPROACHING"); else return this.FinishOrder(); return ACCEPT_ORDER; }, "Order.CollectTreasureNearPosition": function(msg) { if (!this.AbleToMove()) return this.FinishOrder(); this.SetNextState("INDIVIDUAL.COLLECTTREASURE.WALKING"); msg.data.initPos = { 'x': msg.data.x, 'z': msg.data.z }; msg.data.relaxed = true; return ACCEPT_ORDER; }, // States for the special entity representing a group of units moving in formation: "FORMATIONCONTROLLER": { "Order.Walk": function(msg) { if (!this.AbleToMove()) return this.FinishOrder(); this.CallMemberFunction("SetHeldPosition", [msg.data.x, msg.data.z]); this.SetNextState("WALKING"); return ACCEPT_ORDER; }, "Order.WalkAndFight": function(msg) { if (!this.AbleToMove()) return this.FinishOrder(); this.CallMemberFunction("SetHeldPosition", [msg.data.x, msg.data.z]); this.SetNextState("WALKINGANDFIGHTING"); return ACCEPT_ORDER; }, "Order.MoveIntoFormation": function(msg) { if (!this.AbleToMove()) return this.FinishOrder(); this.CallMemberFunction("SetHeldPosition", [msg.data.x, msg.data.z]); this.SetNextState("FORMING"); return ACCEPT_ORDER; }, // Only used by other orders to walk there in formation. "Order.WalkToTargetRange": function(msg) { if (this.CheckRange(msg.data)) return this.FinishOrder(); if (!this.AbleToMove()) return this.FinishOrder(); this.SetNextState("WALKING"); return ACCEPT_ORDER; }, "Order.WalkToTarget": function(msg) { if (this.CheckRange(msg.data)) return this.FinishOrder(); if (!this.AbleToMove()) return this.FinishOrder(); this.SetNextState("WALKING"); return ACCEPT_ORDER; }, "Order.WalkToPointRange": function(msg) { if (this.CheckRange(msg.data)) return this.FinishOrder(); if (!this.AbleToMove()) return this.FinishOrder(); this.SetNextState("WALKING"); return ACCEPT_ORDER; }, "Order.Patrol": function(msg) { if (!this.AbleToMove()) return this.FinishOrder(); this.CallMemberFunction("SetHeldPosition", [msg.data.x, msg.data.z]); this.SetNextState("PATROL.PATROLLING"); return ACCEPT_ORDER; }, "Order.Guard": function(msg) { this.CallMemberFunction("Guard", [msg.data.target, false]); Engine.QueryInterface(this.entity, IID_Formation).Disband(); return ACCEPT_ORDER; }, "Order.Stop": function(msg) { let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.ResetOrderVariant(); if (!this.IsAttackingAsFormation()) this.CallMemberFunction("Stop", [false]); this.FinishOrder(); return ACCEPT_ORDER; // Don't move the members back into formation, // as the formation then resets and it looks odd when walk-stopping. // TODO: this should be improved in the formation reshaping code. }, "Order.Attack": function(msg) { let target = msg.data.target; let allowCapture = msg.data.allowCapture; let cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI); if (cmpTargetUnitAI && cmpTargetUnitAI.IsFormationMember()) target = cmpTargetUnitAI.GetFormationController(); if (!this.CheckFormationTargetAttackRange(target)) { if (this.AbleToMove() && this.CheckTargetVisible(target)) { this.SetNextState("COMBAT.APPROACHING"); return ACCEPT_ORDER; } return this.FinishOrder(); } this.CallMemberFunction("Attack", [target, allowCapture, false]); let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (cmpAttack && cmpAttack.CanAttackAsFormation()) this.SetNextState("COMBAT.ATTACKING"); else this.SetNextState("MEMBER"); return ACCEPT_ORDER; }, "Order.Garrison": function(msg) { if (!Engine.QueryInterface(msg.data.target, msg.data.garrison ? IID_GarrisonHolder : IID_TurretHolder)) return this.FinishOrder(); if (this.CheckTargetRange(msg.data.target, msg.data.garrison ? IID_Garrisonable : IID_Turretable)) { if (!this.AbleToMove() || !this.CheckTargetVisible(msg.data.target)) return this.FinishOrder(); this.SetNextState("GARRISON.APPROACHING"); } else this.SetNextState("GARRISON.GARRISONING"); return ACCEPT_ORDER; }, "Order.Gather": function(msg) { if (this.MustKillGatherTarget(msg.data.target)) { // The target was visible when this order was given, // but could now be invisible. if (!this.CheckTargetVisible(msg.data.target)) { if (msg.data.secondTry === undefined) { msg.data.secondTry = true; this.PushOrderFront("Walk", msg.data.lastPos); } // We couldn't move there, or the target moved away else { let data = msg.data; if (!this.FinishOrder()) this.PushOrderFront("GatherNearPosition", { "x": data.lastPos.x, "z": data.lastPos.z, "type": data.type, "template": data.template }); } return ACCEPT_ORDER; } this.PushOrderFront("Attack", { "target": msg.data.target, "force": !!msg.data.force, "hunting": true, "allowCapture": false, "min": 0, "max": 10 }); return ACCEPT_ORDER; } // TODO: on what should we base this range? if (!this.CheckTargetRangeExplicit(msg.data.target, 0, 10)) { if (!this.CanGather(msg.data.target) || !this.CheckTargetVisible(msg.data.target)) return this.FinishOrder(); // TODO: Should we issue a gather-near-position order // if the target isn't gatherable/doesn't exist anymore? if (!msg.data.secondTry) { msg.data.secondTry = true; this.PushOrderFront("WalkToTargetRange", { "target": msg.data.target, "min": 0, "max": 10 }); return ACCEPT_ORDER; } return this.FinishOrder(); } this.CallMemberFunction("Gather", [msg.data.target, false]); this.SetNextState("MEMBER"); return ACCEPT_ORDER; }, "Order.GatherNearPosition": function(msg) { // TODO: on what should we base this range? if (!this.CheckPointRangeExplicit(msg.data.x, msg.data.z, 0, 20)) { // Out of range; move there in formation this.PushOrderFront("WalkToPointRange", { "x": msg.data.x, "z": msg.data.z, "min": 0, "max": 20 }); return ACCEPT_ORDER; } this.CallMemberFunction("GatherNearPosition", [msg.data.x, msg.data.z, msg.data.type, msg.data.template, false]); this.SetNextState("MEMBER"); return ACCEPT_ORDER; }, "Order.Heal": function(msg) { // TODO: on what should we base this range? if (!this.CheckTargetRangeExplicit(msg.data.target, 0, 10)) { if (!this.TargetIsAlive(msg.data.target) || !this.CheckTargetVisible(msg.data.target)) return this.FinishOrder(); if (!msg.data.secondTry) { msg.data.secondTry = true; this.PushOrderFront("WalkToTargetRange", { "target": msg.data.target, "min": 0, "max": 10 }); return ACCEPT_ORDER; } return this.FinishOrder(); } this.CallMemberFunction("Heal", [msg.data.target, false]); this.SetNextState("MEMBER"); return ACCEPT_ORDER; }, "Order.CollectTreasure": function(msg) { // TODO: on what should we base this range? if (this.CheckTargetRangeExplicit(msg.data.target, 0, 20)) { this.CallMemberFunction("CollectTreasure", [msg.data.target, false, false]); this.SetNextState("MEMBER"); return ACCEPT_ORDER; } if (msg.data.secondTry || !this.CheckTargetVisible(msg.data.target)) return this.FinishOrder(); msg.data.secondTry = true; this.PushOrderFront("WalkToTargetRange", { "target": msg.data.target, "min": 0, "max": 20 }); return ACCEPT_ORDER; }, "Order.CollectTreasureNearPosition": function(msg) { // TODO: on what should we base this range? if (!this.CheckPointRangeExplicit(msg.data.x, msg.data.z, 0, 20)) { this.PushOrderFront("WalkToPointRange", { "x": msg.data.x, "z": msg.data.z, "min": 0, "max": 20 }); return ACCEPT_ORDER; } this.CallMemberFunction("CollectTreasureNearPosition", [msg.data.x, msg.data.z, false, false]); this.SetNextState("MEMBER"); return ACCEPT_ORDER; }, "Order.Repair": function(msg) { // TODO: on what should we base this range? if (!this.CheckTargetRangeExplicit(msg.data.target, 0, 10)) { if (!this.TargetIsAlive(msg.data.target) || !this.CheckTargetVisible(msg.data.target)) return this.FinishOrder(); if (!msg.data.secondTry) { msg.data.secondTry = true; this.PushOrderFront("WalkToTargetRange", { "target": msg.data.target, "min": 0, "max": 10 }); return ACCEPT_ORDER; } return this.FinishOrder(); } this.CallMemberFunction("Repair", [msg.data.target, msg.data.autocontinue, false]); this.SetNextState("MEMBER"); return ACCEPT_ORDER; }, "Order.ReturnResource": function(msg) { // TODO: on what should we base this range? if (!this.CheckTargetRangeExplicit(msg.data.target, 0, 10)) { if (!this.CheckTargetVisible(msg.data.target)) return this.FinishOrder(); if (!msg.data.secondTry) { msg.data.secondTry = true; this.PushOrderFront("WalkToTargetRange", { "target": msg.data.target, "min": 0, "max": 10 }); return ACCEPT_ORDER; } return this.FinishOrder(); } this.CallMemberFunction("ReturnResource", [msg.data.target, false]); this.SetNextState("MEMBER"); return ACCEPT_ORDER; }, "Order.Pack": function(msg) { this.CallMemberFunction("Pack", [false]); this.SetNextState("MEMBER"); return ACCEPT_ORDER; }, "Order.Unpack": function(msg) { this.CallMemberFunction("Unpack", [false]); this.SetNextState("MEMBER"); return ACCEPT_ORDER; }, "Order.DropAtNearestDropSite": function(msg) { this.CallMemberFunction("DropAtNearestDropSite", [false, false]); this.SetNextState("MEMBER"); return ACCEPT_ORDER; }, "IDLE": { "enter": function(msg) { // Turn rearrange off. Otherwise, if the formation is idle // but individual units go off to fight, // any death will rearrange the formation, which looks odd. // Instead, move idle units in formation on a timer. let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(false); // Start the timer on the next turn to catch up with potential stragglers. this.StartTimer(100, 2000); this.isIdle = true; this.CallMemberFunction("ResetIdle"); return false; }, "leave": function() { this.isIdle = false; this.StopTimer(); }, "Timer": function(msg) { let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (!cmpFormation) return; if (this.TestAllMemberFunction("IsIdle")) cmpFormation.MoveMembersIntoFormation(false, false); }, }, "WALKING": { "enter": function() { let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } return false; }, "leave": function() { this.StopTimer(); this.StopMoving(); }, "MovementUpdate": function(msg) { if (msg.veryObstructed && !this.timer) { // It's possible that the controller (with large clearance) // is stuck, but not the individual units. // Ask them to move individually for a little while. this.CallMemberFunction("MoveTo", [this.order.data]); this.StartTimer(3000); return; } else if (this.timer) return; if (msg.likelyFailure || this.CheckRange(this.order.data)) this.FinishOrder(); }, "Timer": function() { // Reenter to reset the pathfinder state. this.SetNextState("WALKING"); } }, "WALKINGANDFIGHTING": { "enter": function(msg) { let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true, "combat"); if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } this.StartTimer(0, 1000); return false; }, "leave": function() { this.StopMoving(); this.StopTimer(); }, "Timer": function(msg) { Engine.ProfileStart("FindWalkAndFightTargets"); this.FindWalkAndFightTargets(); Engine.ProfileStop(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || this.CheckRange(this.order.data)) this.FinishOrder(); }, }, "PATROL": { "enter": function() { let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) { this.FinishOrder(); return true; } // Memorize the origin position in case that we want to go back. if (!this.patrolStartPosOrder) { this.patrolStartPosOrder = cmpPosition.GetPosition(); this.patrolStartPosOrder.targetClasses = this.order.data.targetClasses; this.patrolStartPosOrder.allowCapture = this.order.data.allowCapture; } this.SetAnimationVariant("combat"); return false; }, "leave": function() { delete this.patrolStartPosOrder; this.SetDefaultAnimationVariant(); }, "PATROLLING": { "enter": function() { let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true, "combat"); let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld() || !this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } this.StartTimer(0, 1000); return false; }, "leave": function() { this.StopMoving(); this.StopTimer(); }, "Timer": function(msg) { this.FindWalkAndFightTargets(); }, "MovementUpdate": function(msg) { if (!msg.likelyFailure && !msg.likelySuccess && !this.RelaxedMaxRangeCheck(this.order.data, this.DefaultRelaxedMaxRange)) return; if (this.orderQueue.length == 1) this.PushOrder("Patrol", this.patrolStartPosOrder); this.PushOrder(this.order.type, this.order.data); this.SetNextState("CHECKINGWAYPOINT"); }, }, "CHECKINGWAYPOINT": { "enter": function() { this.StartTimer(0, 1000); this.stopSurveying = 0; // TODO: pick a proper animation return false; }, "leave": function() { this.StopTimer(); delete this.stopSurveying; }, "Timer": function(msg) { if (this.stopSurveying >= +this.template.PatrolWaitTime) { this.FinishOrder(); return; } if (!this.FindWalkAndFightTargets()) ++this.stopSurveying; } } }, "GARRISON": { "APPROACHING": { "enter": function() { if (!this.MoveToTargetRange(this.order.data.target, this.order.data.garrison ? IID_Garrisonable : IID_Turretable)) { this.FinishOrder(); return true; } let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); // If the holder should pickup, warn it so it can take needed action. let cmpHolder = Engine.QueryInterface(this.order.data.target, this.order.data.garrison ? IID_GarrisonHolder : IID_TurretHolder); if (cmpHolder && cmpHolder.CanPickup(this.entity)) { this.pickup = this.order.data.target; // temporary, deleted in "leave" Engine.PostMessage(this.pickup, MT_PickupRequested, { "entity": this.entity, "iid": this.order.data.garrison ? IID_GarrisonHolder : IID_TurretHolder }); } return false; }, "leave": function() { this.StopMoving(); if (this.pickup) { Engine.PostMessage(this.pickup, MT_PickupCanceled, { "entity": this.entity }); delete this.pickup; } }, "MovementUpdate": function(msg) { if (msg.likelyFailure || msg.likelySuccess) this.SetNextState("GARRISONING"); }, }, "GARRISONING": { "enter": function() { this.CallMemberFunction(this.order.data.garrison ? "Garrison" : "OccupyTurret", [this.order.data.target, false]); // We might have been disbanded due to the lack of members. if (Engine.QueryInterface(this.entity, IID_Formation).GetMemberCount()) this.SetNextState("MEMBER"); return true; }, }, }, "FORMING": { "enter": function() { let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } return false; }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (!msg.likelyFailure && !this.CheckRange(this.order.data)) return; this.FinishOrder(); } }, "COMBAT": { "APPROACHING": { "enter": function() { let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true, "combat"); if (!this.MoveFormationToTargetAttackRange(this.order.data.target)) { this.FinishOrder(); return true; } return false; }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { let target = this.order.data.target; let cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI); if (cmpTargetUnitAI && cmpTargetUnitAI.IsFormationMember()) target = cmpTargetUnitAI.GetFormationController(); let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); this.CallMemberFunction("Attack", [target, this.order.data.allowCapture, false]); if (cmpAttack.CanAttackAsFormation()) this.SetNextState("COMBAT.ATTACKING"); else this.SetNextState("MEMBER"); }, }, "ATTACKING": { // Wait for individual members to finish "enter": function(msg) { let target = this.order.data.target; let allowCapture = this.order.data.allowCapture; if (!this.CheckFormationTargetAttackRange(target)) { if (this.CanAttack(target) && this.CheckTargetVisible(target)) { this.SetNextState("COMBAT.APPROACHING"); return true; } this.FinishOrder(); return true; } let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); // TODO fix the rearranging while attacking as formation cmpFormation.SetRearrange(!this.IsAttackingAsFormation()); cmpFormation.MoveMembersIntoFormation(false, false, "combat"); this.StartTimer(200, 200); return false; }, "Timer": function(msg) { let target = this.order.data.target; let allowCapture = this.order.data.allowCapture; if (!this.CheckFormationTargetAttackRange(target)) { if (this.CanAttack(target) && this.CheckTargetVisible(target)) { this.SetNextState("COMBAT.APPROACHING"); return; } this.FinishOrder(); return; } }, "leave": function(msg) { this.StopTimer(); var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (cmpFormation) cmpFormation.SetRearrange(true); }, }, }, // Wait for individual members to finish "MEMBER": { "OrderTargetRenamed": function(msg) { // In general, don't react - we don't want to send spurious messages to members. // This looks odd for hunting however because we wait for all // entities to have clumped around the dead resource before proceeding // so explicitly handle this case. if (this.order && this.order.data && this.order.data.hunting && this.order.data.target == msg.data.newentity && this.orderQueue.length > 1) this.FinishOrder(); }, "enter": function(msg) { // Don't rearrange the formation, as that forces all units to stop // what they're doing. let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (cmpFormation) cmpFormation.SetRearrange(false); // While waiting on members, the formation is more like // a group of unit and does not have a well-defined position, // so move the controller out of the world to enforce that. let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) cmpPosition.MoveOutOfWorld(); this.StartTimer(1000, 1000); return false; }, "Timer": function(msg) { let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (cmpFormation && !cmpFormation.AreAllMembersFinished()) return; if (this.FinishOrder()) { if (this.IsWalkingAndFighting()) this.FindWalkAndFightTargets(); return; } return; }, "leave": function(msg) { this.StopTimer(); // Reform entirely as members might be all over the place now. let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (cmpFormation && (cmpFormation.AreAllMembersIdle() || this.orderQueue.length)) cmpFormation.MoveMembersIntoFormation(true); // Update the held position so entities respond to orders. let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) { let pos = cmpPosition.GetPosition2D(); this.CallMemberFunction("SetHeldPosition", [pos.x, pos.y]); } }, }, }, // States for entities moving as part of a formation: "FORMATIONMEMBER": { "FormationLeave": function(msg) { // Stop moving as soon as the formation disbands // Keep current rotation let facePointAfterMove = this.GetFacePointAfterMove(); this.SetFacePointAfterMove(false); this.StopMoving(); this.SetFacePointAfterMove(facePointAfterMove); // If the controller handled an order but some members rejected it, // they will have no orders and be in the FORMATIONMEMBER.IDLE state. if (this.orderQueue.length) { // We're leaving the formation, so stop our FormationWalk order if (this.FinishOrder()) return; } this.formationAnimationVariant = undefined; this.SetNextState("INDIVIDUAL.IDLE"); }, // Override the LeaveFoundation order since we're not doing // anything more important (and we might be stuck in the WALKING // state forever and need to get out of foundations in that case) "Order.LeaveFoundation": function(msg) { if (!this.WillMoveFromFoundation(msg.data.target)) return this.FinishOrder(); msg.data.min = g_LeaveFoundationRange; this.SetNextState("WALKINGTOPOINT"); return ACCEPT_ORDER; }, "enter": function() { let cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) { this.formationAnimationVariant = cmpFormation.GetFormationAnimationVariant(this.entity); if (this.formationAnimationVariant) this.SetAnimationVariant(this.formationAnimationVariant); else this.SetDefaultAnimationVariant(); } return false; }, "leave": function() { this.SetDefaultAnimationVariant(); this.formationAnimationVariant = undefined; }, "IDLE": "INDIVIDUAL.IDLE", "CHEERING": "INDIVIDUAL.CHEERING", "WALKING": { "enter": function() { let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); cmpUnitMotion.MoveToFormationOffset(this.order.data.target, this.order.data.x, this.order.data.z); if (this.order.data.offsetsChanged) { let cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) this.formationAnimationVariant = cmpFormation.GetFormationAnimationVariant(this.entity); } if (this.formationAnimationVariant) this.SetAnimationVariant(this.formationAnimationVariant); else if (this.order.data.variant) this.SetAnimationVariant(this.order.data.variant); else this.SetDefaultAnimationVariant(); return false; }, "leave": function() { // Don't use the logic from unitMotion, as SetInPosition // has already given us a custom rotation // (or we failed to move and thus don't care.) let facePointAfterMove = this.GetFacePointAfterMove(); this.SetFacePointAfterMove(false); this.StopMoving(); this.SetFacePointAfterMove(facePointAfterMove); }, // Occurs when the unit has reached its destination and the controller // is done moving. The controller is notified. "MovementUpdate": function(msg) { // When walking in formation, we'll only get notified in case of failure // if the formation controller has stopped walking. // Formations can start lagging a lot if many entities request short path // so prefer to finish order early than retry pathing. // (see https://code.wildfiregames.com/rP23806) // (if the message is likelyFailure of likelySuccess, we also want to stop). this.FinishOrder(); }, }, // Special case used by Order.LeaveFoundation "WALKINGTOPOINT": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } return false; }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function() { if (!this.CheckRange(this.order.data)) return; this.FinishOrder(); }, }, }, // States for entities not part of a formation: "INDIVIDUAL": { "Attacked": function(msg) { if (this.GetStance().targetAttackersAlways || !this.order || !this.order.data || !this.order.data.force) this.RespondToTargetedEntities([msg.data.attacker]); }, "GuardedAttacked": function(msg) { // do nothing if we have a forced order in queue before the guard order for (var i = 0; i < this.orderQueue.length; ++i) { if (this.orderQueue[i].type == "Guard") break; if (this.orderQueue[i].data && this.orderQueue[i].data.force) return; } // if we already are targeting another unit still alive, finish with it first if (this.order && (this.order.type == "WalkAndFight" || this.order.type == "Attack")) if (this.order.data.target != msg.data.attacker && this.CanAttack(msg.data.attacker)) return; var cmpIdentity = Engine.QueryInterface(this.entity, IID_Identity); var cmpHealth = Engine.QueryInterface(this.isGuardOf, IID_Health); if (cmpIdentity && cmpIdentity.HasClass("Support") && cmpHealth && cmpHealth.IsInjured()) { if (this.CanHeal(this.isGuardOf)) this.PushOrderFront("Heal", { "target": this.isGuardOf, "force": false }); else if (this.CanRepair(this.isGuardOf)) this.PushOrderFront("Repair", { "target": this.isGuardOf, "autocontinue": false, "force": false }); return; } var cmpBuildingAI = Engine.QueryInterface(msg.data.attacker, IID_BuildingAI); if (cmpBuildingAI && this.CanRepair(this.isGuardOf)) { this.PushOrderFront("Repair", { "target": this.isGuardOf, "autocontinue": false, "force": false }); return; } if (this.CheckTargetVisible(msg.data.attacker)) this.PushOrderFront("Attack", { "target": msg.data.attacker, "force": false, "allowCapture": true }); else { var cmpPosition = Engine.QueryInterface(msg.data.attacker, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return; var pos = cmpPosition.GetPosition(); this.PushOrderFront("WalkAndFight", { "x": pos.x, "z": pos.z, "target": msg.data.attacker, "force": false }); // if we already had a WalkAndFight, keep only the most recent one in case the target has moved if (this.orderQueue[1] && this.orderQueue[1].type == "WalkAndFight") { this.orderQueue.splice(1, 1); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); } } }, "IDLE": { "Order.Cheer": function() { // Do not cheer if there is no cheering time and we are not idle yet. if (!this.cheeringTime || !this.isIdle) return this.FinishOrder(); this.SetNextState("CHEERING"); return ACCEPT_ORDER; }, "enter": function() { // Switch back to idle animation to guarantee we won't // get stuck with an incorrect animation this.SelectAnimation("idle"); // Idle is the default state. If units try, from the IDLE.enter sub-state, to // begin another order, and that order fails (calling FinishOrder), they might // end up in an infinite loop. To avoid this, all methods that could put the unit in // a new state are done on the next turn. // This wastes a turn but avoids infinite loops. // Further, the GUI and AI want to know when a unit is idle, // but sending this info in Idle.enter will send spurious messages. // Pick 100 to execute on the next turn in SP and MP. this.StartTimer(100); return false; }, "leave": function() { let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losRangeQuery) cmpRangeManager.DisableActiveQuery(this.losRangeQuery); if (this.losHealRangeQuery) cmpRangeManager.DisableActiveQuery(this.losHealRangeQuery); if (this.losAttackRangeQuery) cmpRangeManager.DisableActiveQuery(this.losAttackRangeQuery); this.StopTimer(); if (this.isIdle) { if (this.IsFormationMember()) Engine.QueryInterface(this.formationController, IID_Formation).UnsetIdleEntity(this.entity); this.isIdle = false; Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle }); } }, "Attacked": function(msg) { if (this.isIdle && (this.GetStance().targetAttackersAlways || !this.order || !this.order.data || !this.order.data.force)) this.RespondToTargetedEntities([msg.data.attacker]); }, // On the range updates: // We check for idleness to prevent an entity to react only to newly seen entities // when receiving a Los*RangeUpdate on the same turn as the entity becomes idle // since this.FindNew*Targets is called in the timer. "LosRangeUpdate": function(msg) { if (this.isIdle && msg && msg.data && msg.data.added && msg.data.added.length) this.RespondToSightedEntities(msg.data.added); }, "LosHealRangeUpdate": function(msg) { if (this.isIdle && msg && msg.data && msg.data.added && msg.data.added.length) this.RespondToHealableEntities(msg.data.added); }, "LosAttackRangeUpdate": function(msg) { if (this.isIdle && msg && msg.data && msg.data.added && msg.data.added.length && this.GetStance().targetVisibleEnemies) this.AttackEntitiesByPreference(msg.data.added); }, "Timer": function(msg) { if (this.isGuardOf) { this.Guard(this.isGuardOf, false); return; } // If a unit can heal and attack we first want to heal wounded units, // so check if we are a healer and find whether there's anybody nearby to heal. // (If anyone approaches later it'll be handled via LosHealRangeUpdate.) // If anyone in sight gets hurt that will be handled via LosHealRangeUpdate. if (this.IsHealer() && this.FindNewHealTargets()) return; // If we entered the idle state we must have nothing better to do, // so immediately check whether there's anybody nearby to attack. // (If anyone approaches later, it'll be handled via LosAttackRangeUpdate.) if (this.FindNewTargets()) return; if (this.FindSightedEnemies()) return; if (!this.isIdle) { // Move back to the held position if we drifted away. // (only if not a formation member). if (!this.IsFormationMember() && this.GetStance().respondHoldGround && this.heldPosition && !this.CheckPointRangeExplicit(this.heldPosition.x, this.heldPosition.z, 0, 10) && this.WalkToHeldPosition()) return; if (this.IsFormationMember()) { let cmpFormationAI = Engine.QueryInterface(this.formationController, IID_UnitAI); if (!cmpFormationAI || !cmpFormationAI.IsIdle()) return; Engine.QueryInterface(this.formationController, IID_Formation).SetIdleEntity(this.entity); } this.isIdle = true; Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle }); } // Go linger first to prevent all roaming entities // to move all at the same time on map init. if (this.template.RoamDistance) this.SetNextState("LINGERING"); }, "ROAMING": { "enter": function() { this.SetFacePointAfterMove(false); this.MoveRandomly(+this.template.RoamDistance); this.StartTimer(randIntInclusive(+this.template.RoamTimeMin, +this.template.RoamTimeMax)); return false; }, "leave": function() { this.StopMoving(); this.StopTimer(); this.SetFacePointAfterMove(true); }, "Timer": function(msg) { this.SetNextState("LINGERING"); }, "MovementUpdate": function() { this.MoveRandomly(+this.template.RoamDistance); }, }, "LINGERING": { "enter": function() { // ToDo: rename animations? this.SelectAnimation("feeding"); this.StartTimer(randIntInclusive(+this.template.FeedTimeMin, +this.template.FeedTimeMax)); return false; }, "leave": function() { this.ResetAnimation(); this.StopTimer(); }, "Timer": function(msg) { this.SetNextState("ROAMING"); }, }, }, "WALKING": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } return false; }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { // If it looks like the path is failing, and we are close enough stop anyways. // This avoids pathing for an unreachable goal and reduces lag considerably. if (msg.likelyFailure || msg.obstructed && this.RelaxedMaxRangeCheck(this.order.data, this.DefaultRelaxedMaxRange) || this.CheckRange(this.order.data)) this.FinishOrder(); }, }, "WALKINGANDFIGHTING": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } // Show weapons rather than carried resources. this.SetAnimationVariant("combat"); this.StartTimer(0, 1000); return false; }, "Timer": function(msg) { this.FindWalkAndFightTargets(); }, "leave": function(msg) { this.StopMoving(); this.StopTimer(); this.SetDefaultAnimationVariant(); }, "MovementUpdate": function(msg) { // If it looks like the path is failing, and we are close enough stop anyways. // This avoids pathing for an unreachable goal and reduces lag considerably. if (msg.likelyFailure || msg.obstructed && this.RelaxedMaxRangeCheck(this.order.data, this.DefaultRelaxedMaxRange) || this.CheckRange(this.order.data)) this.FinishOrder(); }, }, "PATROL": { "enter": function() { let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) { this.FinishOrder(); return true; } // Memorize the origin position in case that we want to go back. if (!this.patrolStartPosOrder) { this.patrolStartPosOrder = cmpPosition.GetPosition(); this.patrolStartPosOrder.targetClasses = this.order.data.targetClasses; this.patrolStartPosOrder.allowCapture = this.order.data.allowCapture; } this.SetAnimationVariant("combat"); return false; }, "leave": function() { delete this.patrolStartPosOrder; this.SetDefaultAnimationVariant(); }, "PATROLLING": { "enter": function() { let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld() || !this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } this.StartTimer(0, 1000); return false; }, "leave": function() { this.StopMoving(); this.StopTimer(); }, "Timer": function(msg) { this.FindWalkAndFightTargets(); }, "MovementUpdate": function(msg) { if (!msg.likelyFailure && !msg.likelySuccess && !this.RelaxedMaxRangeCheck(this.order.data, this.DefaultRelaxedMaxRange)) return; if (this.orderQueue.length == 1) this.PushOrder("Patrol", this.patrolStartPosOrder); this.PushOrder(this.order.type, this.order.data); this.SetNextState("CHECKINGWAYPOINT"); }, }, "CHECKINGWAYPOINT": { "enter": function() { this.StartTimer(0, 1000); this.stopSurveying = 0; // TODO: pick a proper animation return false; }, "leave": function() { this.StopTimer(); delete this.stopSurveying; }, "Timer": function(msg) { if (this.stopSurveying >= +this.template.PatrolWaitTime) { this.FinishOrder(); return; } if (!this.FindWalkAndFightTargets()) ++this.stopSurveying; } } }, "GUARD": { "RemoveGuard": function() { this.FinishOrder(); }, "ESCORTING": { "enter": function() { if (!this.MoveToTargetRangeExplicit(this.isGuardOf, 0, this.guardRange)) { this.FinishOrder(); return true; } // Show weapons rather than carried resources. this.SetAnimationVariant("combat"); this.StartTimer(0, 1000); this.SetHeldPositionOnEntity(this.isGuardOf); return false; }, "Timer": function(msg) { if (!this.ShouldGuard(this.isGuardOf)) { this.FinishOrder(); return; } let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); if (cmpObstructionManager.IsInTargetRange(this.entity, this.isGuardOf, 0, 3 * this.guardRange, false)) this.TryMatchTargetSpeed(this.isGuardOf, false); this.SetHeldPositionOnEntity(this.isGuardOf); }, "leave": function(msg) { this.StopMoving(); this.ResetSpeedMultiplier(); this.StopTimer(); this.SetDefaultAnimationVariant(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || this.CheckTargetRangeExplicit(this.isGuardOf, 0, this.guardRange)) this.SetNextState("GUARDING"); }, }, "GUARDING": { "enter": function() { this.StartTimer(1000, 1000); this.SetHeldPositionOnEntity(this.entity); this.SetAnimationVariant("combat"); this.FaceTowardsTarget(this.order.data.target); return false; }, "LosAttackRangeUpdate": function(msg) { if (this.GetStance().targetVisibleEnemies) this.AttackEntitiesByPreference(msg.data.added); }, "Timer": function(msg) { if (!this.ShouldGuard(this.isGuardOf)) { this.FinishOrder(); return; } // TODO: find out what to do if we cannot move. if (!this.CheckTargetRangeExplicit(this.isGuardOf, 0, this.guardRange) && this.MoveToTargetRangeExplicit(this.isGuardOf, 0, this.guardRange)) this.SetNextState("ESCORTING"); else { this.FaceTowardsTarget(this.order.data.target); var cmpHealth = Engine.QueryInterface(this.isGuardOf, IID_Health); if (cmpHealth && cmpHealth.IsInjured()) { if (this.CanHeal(this.isGuardOf)) this.PushOrderFront("Heal", { "target": this.isGuardOf, "force": false }); else if (this.CanRepair(this.isGuardOf)) this.PushOrderFront("Repair", { "target": this.isGuardOf, "autocontinue": false, "force": false }); } } }, "leave": function(msg) { this.StopTimer(); this.SetDefaultAnimationVariant(); }, }, }, "FLEEING": { "enter": function() { // We use the distance between the entities to account for ranged attacks this.order.data.distanceToFlee = PositionHelper.DistanceBetweenEntities(this.entity, this.order.data.target) + (+this.template.FleeDistance); let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); // Use unit motion directly to ignore the visibility check. TODO: change this if we add LOS to fauna. if (this.CheckTargetRangeExplicit(this.order.data.target, this.order.data.distanceToFlee, -1) || !cmpUnitMotion || !cmpUnitMotion.MoveToTargetRange(this.order.data.target, this.order.data.distanceToFlee, -1)) { this.FinishOrder(); return true; } this.PlaySound("panic"); this.SetSpeedMultiplier(this.GetRunMultiplier()); return false; }, "OrderTargetRenamed": function(msg) { // To avoid replaying the panic sound, handle this explicitly. let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (this.CheckTargetRangeExplicit(this.order.data.target, this.order.data.distanceToFlee, -1) || !cmpUnitMotion || !cmpUnitMotion.MoveToTargetRange(this.order.data.target, this.order.data.distanceToFlee, -1)) this.FinishOrder(); }, "Attacked": function(msg) { if (msg.data.attacker == this.order.data.target) return; let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); if (cmpObstructionManager.DistanceToTarget(this.entity, msg.data.target) > cmpObstructionManager.DistanceToTarget(this.entity, this.order.data.target)) return; if (this.GetStance().targetAttackersAlways || !this.order || !this.order.data || !this.order.data.force) this.RespondToTargetedEntities([msg.data.attacker]); }, "leave": function() { this.ResetSpeedMultiplier(); this.StopMoving(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || this.CheckTargetRangeExplicit(this.order.data.target, this.order.data.distanceToFlee, -1)) this.FinishOrder(); }, }, "COMBAT": { "Order.LeaveFoundation": function(msg) { // Ignore the order as we're busy. return this.FinishOrder(); }, "Attacked": function(msg) { // If we're already in combat mode, ignore anyone else who's attacking us // unless it's a melee attack since they may be blocking our way to the target if (msg.data.type == "Melee" && (this.GetStance().targetAttackersAlways || !this.order.data.force)) this.RespondToTargetedEntities([msg.data.attacker]); }, "leave": function() { if (!this.formationAnimationVariant) this.SetDefaultAnimationVariant(); }, "APPROACHING": { "enter": function() { if (!this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType)) { this.FinishOrder(); return true; } if (!this.formationAnimationVariant) this.SetAnimationVariant("combat"); this.StartTimer(1000, 1000); return false; }, "leave": function() { this.StopMoving(); this.StopTimer(); }, "Timer": function(msg) { if (this.ShouldAbandonChase(this.order.data.target, this.order.data.force, IID_Attack, this.order.data.attackType)) { this.FinishOrder(); if (this.GetStance().respondHoldGround) this.WalkToHeldPosition(); } else { this.RememberTargetPosition(); if (this.order.data.hunting && this.orderQueue.length > 1 && this.orderQueue[1].type === "Gather") this.RememberTargetPosition(this.orderQueue[1].data); } }, "MovementUpdate": function(msg) { if (msg.likelyFailure) { // This also handles hunting. if (this.orderQueue.length > 1) { this.FinishOrder(); return; } else if (!this.order.data.force || !this.order.data.lastPos) { this.SetNextState("COMBAT.FINDINGNEWTARGET"); return; } // If the order was forced, try moving to the target position, // under the assumption that this is desirable if the target // was somewhat far away - we'll likely end up closer to where // the player hoped we would. let lastPos = this.order.data.lastPos; this.PushOrder("WalkAndFight", { "x": lastPos.x, "z": lastPos.z, "force": false, // Force to true - otherwise structures might be attacked instead of captured, // which is generally not expected (attacking units usually has allowCapture false). "allowCapture": true }); return; } if (this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType)) { if (this.CanUnpack()) { this.PushOrderFront("Unpack", { "force": true }); return; } this.SetNextState("ATTACKING"); } else if (msg.likelySuccess) // Try moving again, // attack range uses a height-related formula and our actual max range might have changed. if (!this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType)) this.FinishOrder(); }, }, "ATTACKING": { "enter": function() { let target = this.order.data.target; let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) { this.order.data.formationTarget = target; target = cmpFormation.GetClosestMember(this.entity); this.order.data.target = target; } this.shouldCheer = false; let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) { this.FinishOrder(); return true; } if (!this.CheckTargetAttackRange(target, this.order.data.attackType)) { if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return true; } this.ProcessMessage("OutOfRange"); return true; } if (!this.formationAnimationVariant) this.SetAnimationVariant("combat"); this.FaceTowardsTarget(this.order.data.target); this.RememberTargetPosition(); if (this.order.data.hunting && this.orderQueue.length > 1 && this.orderQueue[1].type === "Gather") this.RememberTargetPosition(this.orderQueue[1].data); if (!cmpAttack.StartAttacking(this.order.data.target, this.order.data.attackType, IID_UnitAI)) { this.ProcessMessage("TargetInvalidated"); return true; } let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); if (cmpBuildingAI) { cmpBuildingAI.SetUnitAITarget(this.order.data.target); return false; } let cmpUnitAI = Engine.QueryInterface(this.order.data.target, IID_UnitAI); // Units with no cheering time do not cheer. this.shouldCheer = cmpUnitAI && (!cmpUnitAI.IsAnimal() || cmpUnitAI.IsDangerousAnimal()) && this.cheeringTime > 0; return false; }, "leave": function() { let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); if (cmpBuildingAI) cmpBuildingAI.SetUnitAITarget(0); let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (cmpAttack) cmpAttack.StopAttacking(); }, "OutOfRange": function() { if (this.ShouldChaseTargetedEntity(this.order.data.target, this.order.data.force)) { if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } this.SetNextState("CHASING"); return; } this.SetNextState("FINDINGNEWTARGET"); }, "TargetInvalidated": function() { this.SetNextState("FINDINGNEWTARGET"); }, "Attacked": function(msg) { if (this.order.data.attackType == "Capture" && (this.GetStance().targetAttackersAlways || !this.order.data.force) && this.order.data.target != msg.data.attacker && this.GetBestAttackAgainst(msg.data.attacker, true) != "Capture") this.RespondToTargetedEntities([msg.data.attacker]); }, }, "FINDINGNEWTARGET": { "Order.Cheer": function() { if (!this.cheeringTime) return this.FinishOrder(); this.SetNextState("CHEERING"); return ACCEPT_ORDER; }, "enter": function() { // Try to find the formation the target was a part of. let cmpFormation = Engine.QueryInterface(this.order.data.target, IID_Formation); if (!cmpFormation) cmpFormation = Engine.QueryInterface(this.order.data.formationTarget || INVALID_ENTITY, IID_Formation); // If the target is a formation, pick closest member. if (cmpFormation) { let filter = (t) => this.CanAttack(t); this.order.data.formationTarget = this.order.data.target; let target = cmpFormation.GetClosestMember(this.entity, filter); this.order.data.target = target; this.SetNextState("COMBAT.ATTACKING"); return true; } // Can't reach it, no longer owned by enemy, or it doesn't exist any more - give up // except if in WalkAndFight mode where we look for more enemies around before moving again. if (this.FinishOrder()) { if (this.IsWalkingAndFighting()) { Engine.ProfileStart("FindWalkAndFightTargets"); this.FindWalkAndFightTargets(); Engine.ProfileStop(); } return true; } if (this.FindNewTargets()) return true; if (this.GetStance().respondHoldGround) this.WalkToHeldPosition(); if (this.shouldCheer) { this.Cheer(); this.CallPlayerOwnedEntitiesFunctionInRange("Cheer", [], this.notifyToCheerInRange); } return true; }, }, "CHASING": { "Order.MoveToChasingPoint": function(msg) { if (this.CheckPointRangeExplicit(msg.data.x, msg.data.z, 0, msg.data.max) || !this.AbleToMove()) return this.FinishOrder(); msg.data.relaxed = true; this.StopTimer(); this.SetNextState("MOVINGTOPOINT"); return ACCEPT_ORDER; }, "enter": function() { if (!this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType)) { this.FinishOrder(); return true; } if (!this.formationAnimationVariant) this.SetAnimationVariant("combat"); var cmpUnitAI = Engine.QueryInterface(this.order.data.target, IID_UnitAI); if (cmpUnitAI && cmpUnitAI.IsFleeing()) this.SetSpeedMultiplier(this.GetRunMultiplier()); this.StartTimer(1000, 1000); return false; }, "leave": function() { this.ResetSpeedMultiplier(); this.StopMoving(); this.StopTimer(); }, "Timer": function(msg) { if (this.ShouldAbandonChase(this.order.data.target, this.order.data.force, IID_Attack, this.order.data.attackType)) { this.FinishOrder(); if (this.GetStance().respondHoldGround) this.WalkToHeldPosition(); } else { this.RememberTargetPosition(); if (this.order.data.hunting && this.orderQueue.length > 1 && this.orderQueue[1].type === "Gather") this.RememberTargetPosition(this.orderQueue[1].data); } }, "MovementUpdate": function(msg) { if (msg.likelyFailure) { // This also handles hunting. if (this.orderQueue.length > 1) { this.FinishOrder(); return; } else if (!this.order.data.force) { this.SetNextState("COMBAT.FINDINGNEWTARGET"); return; } else if (this.order.data.lastPos) { let lastPos = this.order.data.lastPos; let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); this.PushOrder("MoveToChasingPoint", { "x": lastPos.x, "z": lastPos.z, "max": cmpAttack.GetRange(this.order.data.attackType).max, "force": true }); return; } } if (this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType)) { if (this.CanUnpack()) { this.PushOrderFront("Unpack", { "force": true }); return; } this.SetNextState("ATTACKING"); } else if (msg.likelySuccess) // Try moving again, // attack range uses a height-related formula and our actual max range might have changed. if (!this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType)) this.FinishOrder(); }, "MOVINGTOPOINT": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } return false; }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { // If it looks like the path is failing, and we are close enough from wanted range // stop anyways. This avoids pathing for an unreachable goal and reduces lag considerably. if (msg.likelyFailure || msg.obstructed && this.RelaxedMaxRangeCheck(this.order.data, this.order.data.max + this.DefaultRelaxedMaxRange) || !msg.obstructed && this.CheckRange(this.order.data)) this.FinishOrder(); }, }, }, }, "GATHER": { "enter": function() { let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (cmpResourceGatherer) cmpResourceGatherer.AddToPlayerCounter(this.order.data.type.generic); return false; }, "leave": function() { let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (cmpResourceGatherer) cmpResourceGatherer.RemoveFromPlayerCounter(); // Show the carried resource, if we've gathered anything. this.SetDefaultAnimationVariant(); }, "APPROACHING": { "enter": function() { this.gatheringTarget = this.order.data.target; // temporary, deleted in "leave". // If we can't move, assume we'll fail any subsequent order // and finish the order entirely to avoid an infinite loop. if (!this.AbleToMove()) { this.FinishOrder(); return true; } let cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); let cmpMirage = Engine.QueryInterface(this.gatheringTarget, IID_Mirage); if ((!cmpMirage || !cmpMirage.Mirages(IID_ResourceSupply)) && (!cmpSupply || !cmpSupply.AddGatherer(this.entity)) || !this.MoveTo(this.order.data, IID_ResourceGatherer)) { // If the target's last known position is in FOW, try going there // and hope that we might find it then. let lastPos = this.order.data.lastPos; if (this.gatheringTarget != INVALID_ENTITY && lastPos && !this.CheckPositionVisible(lastPos.x, lastPos.z)) { this.PushOrderFront("Walk", { "x": lastPos.x, "z": lastPos.z, "force": this.order.data.force }); return true; } this.SetNextState("FINDINGNEWTARGET"); return true; } if (this.CheckRange(this.order.data, IID_ResourceGatherer)) { this.SetNextState("GATHERING"); return true; } this.SetAnimationVariant("approach_" + this.order.data.type.specific); return false; }, "MovementUpdate": function(msg) { // The GATHERING timer will handle finding a valid resource. if (msg.likelyFailure) this.SetNextState("FINDINGNEWTARGET"); else if (this.CheckRange(this.order.data, IID_ResourceGatherer)) this.SetNextState("GATHERING"); }, "leave": function() { this.StopMoving(); if (!this.gatheringTarget) return; let cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); if (cmpSupply) cmpSupply.RemoveGatherer(this.entity); delete this.gatheringTarget; }, }, // Walking to a good place to gather resources near, used by GatherNearPosition "WALKING": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } this.SetAnimationVariant("approach_" + this.order.data.type.specific); return false; }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || msg.obstructed && this.RelaxedMaxRangeCheck(this.order.data, this.DefaultRelaxedMaxRange) || this.CheckRange(this.order.data)) this.SetNextState("FINDINGNEWTARGET"); }, }, "GATHERING": { "enter": function() { let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (!cmpResourceGatherer) { this.FinishOrder(); return true; } if (!this.CheckTargetRange(this.order.data.target, IID_ResourceGatherer)) { this.ProcessMessage("OutOfRange"); return true; } // If this order was forced, the player probably gave it, but now we've reached the target // switch to an unforced order (can be interrupted by attacks) this.order.data.force = false; this.order.data.autoharvest = true; if (!cmpResourceGatherer.StartGathering(this.order.data.target, IID_UnitAI)) { this.ProcessMessage("TargetInvalidated"); return true; } this.FaceTowardsTarget(this.order.data.target); return false; }, "leave": function() { let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (cmpResourceGatherer) cmpResourceGatherer.StopGathering(); }, "InventoryFilled": function(msg) { this.SetNextState("RETURNINGRESOURCE"); }, "OutOfRange": function(msg) { if (this.MoveToTargetRange(this.order.data.target, IID_ResourceGatherer)) this.SetNextState("APPROACHING"); // Our target is no longer visible - go to its last known position first // and then hopefully it will become visible. else if (!this.CheckTargetVisible(this.order.data.target) && this.order.data.lastPos) this.PushOrderFront("Walk", { "x": this.order.data.lastPos.x, "z": this.order.data.lastPos.z, "force": this.order.data.force }); else this.SetNextState("FINDINGNEWTARGET"); }, "TargetInvalidated": function(msg) { this.SetNextState("FINDINGNEWTARGET"); }, }, "FINDINGNEWTARGET": { "enter": function() { const previousForced = this.order.data.force; let previousTarget = this.order.data.target; let resourceTemplate = this.order.data.template; let resourceType = this.order.data.type; // Give up on this order and try our next queued order // but first check what is our next order and, if needed, insert a returnResource order let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (cmpResourceGatherer.IsCarrying(resourceType.generic) && this.orderQueue.length > 1 && this.orderQueue[1] !== "ReturnResource" && (this.orderQueue[1].type !== "Gather" || this.orderQueue[1].data.type.generic !== resourceType.generic)) { let nearestDropsite = this.FindNearestDropsite(resourceType.generic); if (nearestDropsite) this.orderQueue.splice(1, 0, { "type": "ReturnResource", "data": { "target": nearestDropsite, "force": false } }); } // Must go before FinishOrder or this.order will be undefined. let initPos = this.order.data.initPos; if (this.FinishOrder()) return true; // No remaining orders - pick a useful default behaviour let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return true; let filter = (ent, type, template) => { if (previousTarget == ent) return false; // Don't switch to a different type of huntable animal. return type.specific == resourceType.specific && (type.specific != "meat" || resourceTemplate == template); }; // Current position is often next to a dropsite. // But don't use that on forced orders, as the order may want us to go // to the other side of the map on purpose. let pos = cmpPosition.GetPosition(); let nearbyResource; if (!previousForced) nearbyResource = this.FindNearbyResource(Vector2D.from3D(pos), filter); // If there is an initPos, search there as well when we haven't found anything. // Otherwise set initPos to our current pos. if (!initPos) initPos = { 'x': pos.X, 'z': pos.Z }; else if (!nearbyResource || previousForced) nearbyResource = this.FindNearbyResource(new Vector2D(initPos.x, initPos.z), filter); if (nearbyResource) { this.PerformGather(nearbyResource, false, false); return true; } // Failing that, try to move there and se if we are more lucky: maybe there are resources in FOW. // Only move if we are some distance away (TODO: pick the distance better?). // Using the default relaxed range check since that is used in the WALKING-state. if (!this.CheckPointRangeExplicit(initPos.x, initPos.z, 0, this.DefaultRelaxedMaxRange)) { this.GatherNearPosition(initPos.x, initPos.z, resourceType, resourceTemplate); return true; } // Nothing else to gather - if we're carrying anything then we should // drop it off, and if not then we might as well head to the dropsite // anyway because that's a nice enough place to congregate and idle let nearestDropsite = this.FindNearestDropsite(resourceType.generic); if (nearestDropsite) { this.PushOrderFront("ReturnResource", { "target": nearestDropsite, "force": false }); return true; } // No dropsites - just give up. return true; }, }, "RETURNINGRESOURCE": { "enter": function() { let nearestDropsite = this.FindNearestDropsite(this.order.data.type.generic); if (!nearestDropsite) { // The player expects the unit to move upon failure. let formerTarget = this.order.data.target; if (!this.FinishOrder()) this.WalkToTarget(formerTarget); return true; } this.order.data.formerTarget = this.order.data.target; this.order.data.target = nearestDropsite; if (this.CheckTargetRange(this.order.data.target, IID_ResourceGatherer)) { this.SetNextState("DROPPINGRESOURCES"); return true; } this.SetDefaultAnimationVariant(); this.SetNextState("APPROACHING"); return true; }, "leave": function() { }, "APPROACHING": "INDIVIDUAL.RETURNRESOURCE.APPROACHING", "DROPPINGRESOURCES": { "enter": function() { let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (this.CanReturnResource(this.order.data.target, true, cmpResourceGatherer)) { cmpResourceGatherer.CommitResources(this.order.data.target); this.SetNextState("GATHER.APPROACHING"); } else this.SetNextState("RETURNINGRESOURCE"); this.order.data.target = this.order.data.formerTarget; return true; }, "leave": function() { }, }, }, }, "HEAL": { "Attacked": function(msg) { if (!this.GetStance().respondStandGround && !this.order.data.force) this.Flee(msg.data.attacker, false); }, "APPROACHING": { "enter": function() { if (this.CheckRange(this.order.data, IID_Heal)) { this.SetNextState("HEALING"); return true; } if (!this.MoveTo(this.order.data, IID_Heal)) { this.FinishOrder(); return true; } this.StartTimer(1000, 1000); return false; }, "leave": function() { this.StopMoving(); this.StopTimer(); }, "Timer": function(msg) { if (this.ShouldAbandonChase(this.order.data.target, this.order.data.force, IID_Heal, null)) this.SetNextState("FINDINGNEWTARGET"); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || this.CheckRange(this.order.data, IID_Heal)) this.SetNextState("HEALING"); }, }, "HEALING": { "enter": function() { let cmpHeal = Engine.QueryInterface(this.entity, IID_Heal); if (!cmpHeal) { this.FinishOrder(); return true; } if (!this.CheckRange(this.order.data, IID_Heal)) { this.ProcessMessage("OutOfRange"); return true; } if (!cmpHeal.StartHealing(this.order.data.target, IID_UnitAI)) { this.ProcessMessage("TargetInvalidated"); return true; } this.FaceTowardsTarget(this.order.data.target); return false; }, "leave": function() { let cmpHeal = Engine.QueryInterface(this.entity, IID_Heal); if (cmpHeal) cmpHeal.StopHealing(); }, "OutOfRange": function(msg) { if (this.ShouldChaseTargetedEntity(this.order.data.target, this.order.data.force)) { if (this.CanPack()) this.PushOrderFront("Pack", { "force": true }); else this.SetNextState("APPROACHING"); } else this.SetNextState("FINDINGNEWTARGET"); }, "TargetInvalidated": function(msg) { this.SetNextState("FINDINGNEWTARGET"); }, }, "FINDINGNEWTARGET": { "enter": function() { // If we have another order, do that instead. if (this.FinishOrder()) return true; if (this.FindNewHealTargets()) return true; if (this.GetStance().respondHoldGround) this.WalkToHeldPosition(); // We quit this state right away. return true; }, }, }, // Returning to dropsite "RETURNRESOURCE": { "APPROACHING": { "enter": function() { if (!this.MoveTo(this.order.data, IID_ResourceGatherer)) { this.FinishOrder(); return true; } return false; }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || this.CheckTargetRange(this.order.data.target, IID_ResourceGatherer)) this.SetNextState("DROPPINGRESOURCES"); }, }, "DROPPINGRESOURCES": { "enter": function() { let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (this.CanReturnResource(this.order.data.target, true, cmpResourceGatherer)) { cmpResourceGatherer.CommitResources(this.order.data.target); // Stop showing the carried resource animation. this.SetDefaultAnimationVariant(); this.FinishOrder(); return true; } let nearby = this.FindNearestDropsite(cmpResourceGatherer.GetMainCarryingType()); this.FinishOrder(); if (nearby) this.PushOrderFront("ReturnResource", { "target": nearby, "force": false }); return true; }, "leave": function() { }, }, }, "COLLECTTREASURE": { "leave": function() { }, "APPROACHING": { "enter": function() { // If we can't move, assume we'll fail any subsequent order // and finish the order entirely to avoid an infinite loop. if (!this.AbleToMove()) { this.FinishOrder(); return true; } if (!this.MoveToTargetRange(this.order.data.target, IID_TreasureCollector)) { this.SetNextState("FINDINGNEWTARGET"); return true; } return false; }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (this.CheckTargetRange(this.order.data.target, IID_TreasureCollector)) this.SetNextState("COLLECTING"); else if (msg.likelyFailure) this.SetNextState("FINDINGNEWTARGET"); }, }, "COLLECTING": { "enter": function() { let cmpTreasureCollector = Engine.QueryInterface(this.entity, IID_TreasureCollector); if (!cmpTreasureCollector.StartCollecting(this.order.data.target, IID_UnitAI)) { this.ProcessMessage("TargetInvalidated"); return true; } this.FaceTowardsTarget(this.order.data.target); return false; }, "leave": function() { let cmpTreasureCollector = Engine.QueryInterface(this.entity, IID_TreasureCollector); if (cmpTreasureCollector) cmpTreasureCollector.StopCollecting(); }, "OutOfRange": function(msg) { this.SetNextState("APPROACHING"); }, "TargetInvalidated": function(msg) { this.SetNextState("FINDINGNEWTARGET"); }, }, "FINDINGNEWTARGET": { "enter": function() { let oldTarget = this.order.data.target || INVALID_ENTITY; // Switch to the next order (if any). if (this.FinishOrder()) return true; let nearbyTreasure = this.FindNearbyTreasure(this.TargetPosOrEntPos(oldTarget)); if (nearbyTreasure) this.CollectTreasure(nearbyTreasure, true); return true; }, }, // Walking to a good place to collect treasures near, used by CollectTreasureNearPosition. "WALKING": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } return false; }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || msg.obstructed && this.RelaxedMaxRangeCheck(this.order.data, this.DefaultRelaxedMaxRange) || this.CheckRange(this.order.data)) this.SetNextState("FINDINGNEWTARGET"); }, }, }, "TRADE": { "Attacked": function(msg) { // Ignore attack // TODO: Inform player }, "leave": function() { }, "APPROACHINGMARKET": { "enter": function() { if (!this.MoveToMarket(this.order.data.target)) { this.FinishOrder(); return true; } return false; }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (!msg.likelyFailure && !this.CheckRange(this.order.data.nextTarget, IID_Trader)) return; if (this.waypoints && this.waypoints.length) { if (!this.MoveToMarket(this.order.data.target)) this.FinishOrder(); } else this.SetNextState("TRADING"); }, }, "TRADING": { "enter": function() { if (!this.CanTrade(this.order.data.target)) { this.FinishOrder(); return true; } if (!this.CheckTargetRange(this.order.data.target, IID_Trader)) { this.SetNextState("APPROACHINGMARKET"); return true; } let cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); let nextMarket = cmpTrader.PerformTrade(this.order.data.target); let amount = cmpTrader.GetGoods().amount; if (!nextMarket || !amount || !amount.traderGain) { this.FinishOrder(); return true; } this.order.data.target = nextMarket; if (this.order.data.route && this.order.data.route.length) { this.waypoints = this.order.data.route.slice(); if (this.order.data.target == cmpTrader.GetSecondMarket()) this.waypoints.reverse(); } this.SetNextState("APPROACHINGMARKET"); return true; }, "leave": function() { }, }, "TradingCanceled": function(msg) { if (msg.market != this.order.data.target) return; let cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); let otherMarket = cmpTrader && cmpTrader.GetFirstMarket(); if (otherMarket) this.WalkToTarget(otherMarket); else this.FinishOrder(); }, }, "REPAIR": { "APPROACHING": { "enter": function() { if (!this.MoveTo(this.order.data, IID_Builder)) { this.FinishOrder(); return true; } return false; }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || msg.likelySuccess) this.SetNextState("REPAIRING"); }, }, "REPAIRING": { "enter": function() { let cmpBuilder = Engine.QueryInterface(this.entity, IID_Builder); if (!cmpBuilder) { this.FinishOrder(); return true; } // If this order was forced, the player probably gave it, but now we've reached the target // switch to an unforced order (can be interrupted by attacks) if (this.order.data.force) this.order.data.autoharvest = true; this.order.data.force = false; if (!this.CheckTargetRange(this.order.data.target, IID_Builder)) { this.ProcessMessage("OutOfRange"); return true; } let cmpHealth = Engine.QueryInterface(this.order.data.target, IID_Health); if (cmpHealth && cmpHealth.GetHitpoints() >= cmpHealth.GetMaxHitpoints()) { // The building was already finished/fully repaired before we arrived; // let the ConstructionFinished handler handle this. this.ConstructionFinished({ "entity": this.order.data.target, "newentity": this.order.data.target }); return true; } if (!cmpBuilder.StartRepairing(this.order.data.target, IID_UnitAI)) { this.ProcessMessage("TargetInvalidated"); return true; } this.FaceTowardsTarget(this.order.data.target); return false; }, "leave": function() { let cmpBuilder = Engine.QueryInterface(this.entity, IID_Builder); if (cmpBuilder) cmpBuilder.StopRepairing(); }, "OutOfRange": function(msg) { this.SetNextState("APPROACHING"); }, "TargetInvalidated": function(msg) { this.FinishOrder(); }, }, "ConstructionFinished": function(msg) { if (msg.data.entity != this.order.data.target) return; // ignore other buildings let oldData = this.order.data; // Save the current state so we can continue walking if necessary // FinishOrder() below will switch to IDLE if there's no order, which sets the idle animation. // Idle animation while moving towards finished construction looks weird (ghosty). let oldState = this.GetCurrentState(); let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); let canReturnResources = this.CanReturnResource(msg.data.newentity, true, cmpResourceGatherer); if (this.CheckTargetRange(msg.data.newentity, IID_Builder) && canReturnResources) { cmpResourceGatherer.CommitResources(msg.data.newentity); this.SetDefaultAnimationVariant(); } // Switch to the next order (if any) if (this.FinishOrder()) { if (canReturnResources) { // We aren't in range, but we can still return resources there: always do so. this.SetDefaultAnimationVariant(); this.PushOrderFront("ReturnResource", { "target": msg.data.newentity, "force": false }); } return; } if (canReturnResources) { // We aren't in range, but we can still return resources there: always do so. this.SetDefaultAnimationVariant(); this.PushOrderFront("ReturnResource", { "target": msg.data.newentity, "force": false }); } // No remaining orders - pick a useful default behaviour // If autocontinue explicitly disabled (e.g. by AI) then // do nothing automatically if (!oldData.autocontinue) return; // If this building was e.g. a farm of ours, the entities that received // the build command should start gathering from it if ((oldData.force || oldData.autoharvest) && this.CanGather(msg.data.newentity)) { this.PerformGather(msg.data.newentity, true, false); return; } // If this building was e.g. a farmstead of ours, entities that received // the build command should look for nearby resources to gather if ((oldData.force || oldData.autoharvest) && this.CanReturnResource(msg.data.newentity, false, cmpResourceGatherer)) { let cmpResourceDropsite = Engine.QueryInterface(msg.data.newentity, IID_ResourceDropsite); let types = cmpResourceDropsite.GetTypes(); // TODO: Slightly undefined behavior here, we don't know what type of resource will be collected, // may cause problems for AIs (especially hunting fast animals), but avoid ugly hacks to fix that! let nearby = this.FindNearbyResource(this.TargetPosOrEntPos(msg.data.newentity), (ent, type, template) => types.indexOf(type.generic) != -1); if (nearby) { this.PerformGather(nearby, true, false); return; } } let nearbyFoundation = this.FindNearbyFoundation(this.TargetPosOrEntPos(msg.data.newentity)); if (nearbyFoundation) { this.AddOrder("Repair", { "target": nearbyFoundation, "autocontinue": oldData.autocontinue, "force": false }, true); return; } // Unit was approaching and there's nothing to do now, so switch to walking if (oldState.endsWith("REPAIR.APPROACHING")) // We're already walking to the given point, so add this as a order. this.WalkToTarget(msg.data.newentity, true); }, }, "GARRISON": { "APPROACHING": { "enter": function() { if (this.order.data.garrison ? !this.CanGarrison(this.order.data.target) : !this.CanOccupyTurret(this.order.data.target)) { this.FinishOrder(); return true; } if (!this.MoveToTargetRange(this.order.data.target, this.order.data.garrison ? IID_Garrisonable : IID_Turretable)) { this.FinishOrder(); return true; } if (this.pickup) Engine.PostMessage(this.pickup, MT_PickupCanceled, { "entity": this.entity }); let cmpHolder = Engine.QueryInterface(this.order.data.target, this.order.data.garrison ? IID_GarrisonHolder : IID_TurretHolder); if (cmpHolder && cmpHolder.CanPickup(this.entity)) { this.pickup = this.order.data.target; Engine.PostMessage(this.pickup, MT_PickupRequested, { "entity": this.entity, "iid": this.order.data.garrison ? IID_GarrisonHolder : IID_TurretHolder }); } return false; }, "leave": function() { if (this.pickup) { Engine.PostMessage(this.pickup, MT_PickupCanceled, { "entity": this.entity }); delete this.pickup; } this.StopMoving(); }, "MovementUpdate": function(msg) { if (!msg.likelyFailure && !msg.likelySuccess) return; if (this.CheckTargetRange(this.order.data.target, this.order.data.garrison ? IID_Garrisonable : IID_Turretable)) this.SetNextState("GARRISONING"); else { // Unable to reach the target, try again (or follow if it is a moving target) // except if the target does not exist anymore or its orders have changed. if (this.pickup) { let cmpUnitAI = Engine.QueryInterface(this.pickup, IID_UnitAI); if (!cmpUnitAI || (!cmpUnitAI.HasPickupOrder(this.entity) && !cmpUnitAI.IsIdle())) this.FinishOrder(); } } }, }, "GARRISONING": { "enter": function() { let target = this.order.data.target; if (this.order.data.garrison) { let cmpGarrisonable = Engine.QueryInterface(this.entity, IID_Garrisonable); if (!cmpGarrisonable || !cmpGarrisonable.Garrison(target)) { this.FinishOrder(); return true; } } else { let cmpTurretable = Engine.QueryInterface(this.entity, IID_Turretable); if (!cmpTurretable || !cmpTurretable.OccupyTurret(target)) { this.FinishOrder(); return true; } } if (this.formationController) { let cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) { let rearrange = cmpFormation.rearrange; cmpFormation.SetRearrange(false); cmpFormation.RemoveMembers([this.entity]); cmpFormation.SetRearrange(rearrange); } } let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (this.CanReturnResource(target, true, cmpResourceGatherer)) { cmpResourceGatherer.CommitResources(target); this.SetDefaultAnimationVariant(); } this.FinishOrder(); return true; }, "leave": function() { }, }, }, "CHEERING": { "enter": function() { this.SelectAnimation("promotion"); this.StartTimer(this.cheeringTime); return false; }, "leave": function() { // PushOrderFront preserves the cheering order, // which can lead to very bad behaviour, so make // sure to delete any queued ones. for (let i = 1; i < this.orderQueue.length; ++i) if (this.orderQueue[i].type == "Cheer") this.orderQueue.splice(i--, 1); this.StopTimer(); this.ResetAnimation(); }, "LosRangeUpdate": function(msg) { if (msg && msg.data && msg.data.added && msg.data.added.length) this.RespondToSightedEntities(msg.data.added); }, "LosHealRangeUpdate": function(msg) { if (msg && msg.data && msg.data.added && msg.data.added.length) this.RespondToHealableEntities(msg.data.added); }, "LosAttackRangeUpdate": function(msg) { if (msg && msg.data && msg.data.added && msg.data.added.length && this.GetStance().targetVisibleEnemies) this.AttackEntitiesByPreference(msg.data.added); }, "Timer": function(msg) { this.FinishOrder(); }, }, "PACKING": { "enter": function() { let cmpPack = Engine.QueryInterface(this.entity, IID_Pack); cmpPack.Pack(); return false; }, "Order.CancelPack": function(msg) { this.FinishOrder(); return ACCEPT_ORDER; }, "PackFinished": function(msg) { this.FinishOrder(); }, "leave": function() { let cmpPack = Engine.QueryInterface(this.entity, IID_Pack); cmpPack.CancelPack(); }, "Attacked": function(msg) { // Ignore attacks while packing }, }, "UNPACKING": { "enter": function() { let cmpPack = Engine.QueryInterface(this.entity, IID_Pack); cmpPack.Unpack(); return false; }, "Order.CancelUnpack": function(msg) { this.FinishOrder(); return ACCEPT_ORDER; }, "PackFinished": function(msg) { this.FinishOrder(); }, "leave": function() { let cmpPack = Engine.QueryInterface(this.entity, IID_Pack); cmpPack.CancelPack(); }, "Attacked": function(msg) { // Ignore attacks while unpacking }, }, "PICKUP": { "APPROACHING": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } return false; }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || msg.likelySuccess) this.SetNextState("LOADING"); }, "PickupCanceled": function() { this.FinishOrder(); }, }, "LOADING": { "enter": function() { let cmpHolder = Engine.QueryInterface(this.entity, this.order.data.iid); if (!cmpHolder || cmpHolder.IsFull()) { this.FinishOrder(); return true; } return false; }, "PickupCanceled": function() { this.FinishOrder(); }, }, }, }, }; UnitAI.prototype.Init = function() { this.orderQueue = []; // current order is at the front of the list this.order = undefined; // always == this.orderQueue[0] this.formationController = INVALID_ENTITY; // entity with IID_Formation that we belong to this.isIdle = false; this.heldPosition = undefined; // Queue of remembered works this.workOrders = []; this.isGuardOf = undefined; this.formationAnimationVariant = undefined; this.cheeringTime = +(this.template.CheeringTime || 0); this.SetStance(this.template.DefaultStance); }; /** * @param {cmpTurretable} cmpTurretable - Optionally the component to save a query here. * @return {boolean} - Whether we are occupying a turret point. */ UnitAI.prototype.IsTurret = function(cmpTurretable) { if (!cmpTurretable) cmpTurretable = Engine.QueryInterface(this.entity, IID_Turretable); return cmpTurretable && cmpTurretable.HolderID() != INVALID_ENTITY; }; UnitAI.prototype.IsFormationController = function() { return (this.template.FormationController == "true"); }; UnitAI.prototype.IsFormationMember = function() { return (this.formationController != INVALID_ENTITY); }; UnitAI.prototype.GetFormationsList = function() { return this.template.Formations?._string?.split(/\s+/) || []; }; UnitAI.prototype.CanUseFormation = function(formation) { return this.GetFormationsList().includes(formation); }; /** * For now, entities with a RoamDistance are animals. */ UnitAI.prototype.IsAnimal = function() { return !!this.template.RoamDistance; }; /** * ToDo: Make this not needed by fixing gaia * range queries in BuildingAI and UnitAI regarding * animals and other gaia entities. */ UnitAI.prototype.IsDangerousAnimal = function() { return this.IsAnimal() && this.GetStance().targetVisibleEnemies && !!Engine.QueryInterface(this.entity, IID_Attack); }; UnitAI.prototype.IsHealer = function() { return Engine.QueryInterface(this.entity, IID_Heal); }; UnitAI.prototype.IsIdle = function() { return this.isIdle; }; /** * Used by formation controllers to toggle the idleness of their members. */ UnitAI.prototype.ResetIdle = function() { let shouldBeIdle = this.GetCurrentState().endsWith(".IDLE"); if (this.isIdle == shouldBeIdle) return; this.isIdle = shouldBeIdle; Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle }); }; UnitAI.prototype.SetGarrisoned = function() { // UnitAI caches its own garrisoned state for performance. this.isGarrisoned = true; this.SetImmobile(); }; UnitAI.prototype.UnsetGarrisoned = function() { delete this.isGarrisoned; this.SetMobile(); }; UnitAI.prototype.ShouldRespondToEndOfAlert = function() { return !this.orderQueue.length || this.orderQueue[0].type == "Garrison"; }; UnitAI.prototype.SetImmobile = function() { if (this.isImmobile) return; this.isImmobile = true; Engine.PostMessage(this.entity, MT_UnitAbleToMoveChanged, { "entity": this.entity, "ableToMove": this.AbleToMove() }); }; UnitAI.prototype.SetMobile = function() { if (!this.isImmobile) return; delete this.isImmobile; Engine.PostMessage(this.entity, MT_UnitAbleToMoveChanged, { "entity": this.entity, "ableToMove": this.AbleToMove() }); }; /** * @param cmpUnitMotion - optionally pass unitMotion to avoid querying it here * @returns true if the entity can move, i.e. has UnitMotion and isn't immobile. */ UnitAI.prototype.AbleToMove = function(cmpUnitMotion) { if (this.isImmobile) return false; if (!cmpUnitMotion) cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return !!cmpUnitMotion; }; UnitAI.prototype.IsFleeing = function() { var state = this.GetCurrentState().split(".").pop(); return (state == "FLEEING"); }; UnitAI.prototype.IsWalking = function() { var state = this.GetCurrentState().split(".").pop(); return (state == "WALKING"); }; /** * Return true if the current order is WalkAndFight or Patrol. */ UnitAI.prototype.IsWalkingAndFighting = function() { if (this.IsFormationMember()) return false; return this.orderQueue.length > 0 && (this.orderQueue[0].type == "WalkAndFight" || this.orderQueue[0].type == "Patrol"); }; UnitAI.prototype.OnCreate = function() { if (this.IsFormationController()) this.UnitFsm.Init(this, "FORMATIONCONTROLLER.IDLE"); else this.UnitFsm.Init(this, "INDIVIDUAL.IDLE"); this.isIdle = true; }; UnitAI.prototype.OnDiplomacyChanged = function(msg) { let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (cmpOwnership && cmpOwnership.GetOwner() == msg.player) this.SetupRangeQueries(); if (this.isGuardOf && !IsOwnedByMutualAllyOfEntity(this.entity, this.isGuardOf)) this.RemoveGuard(); }; UnitAI.prototype.OnOwnershipChanged = function(msg) { this.SetupRangeQueries(); if (this.isGuardOf && (msg.to == INVALID_PLAYER || !IsOwnedByMutualAllyOfEntity(this.entity, this.isGuardOf))) this.RemoveGuard(); // If the unit isn't being created or dying, reset stance and clear orders if (msg.to != INVALID_PLAYER && msg.from != INVALID_PLAYER) { // Switch to a virgin state to let states execute their leave handlers. // Except if (un)packing, in which case we only clear the order queue. if (this.IsPacking()) { this.orderQueue.length = Math.min(this.orderQueue.length, 1); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); } else { const state = this.GetCurrentState(); // Special "will be destroyed soon" mode - do nothing. if (state === "") return; const index = state.indexOf("."); if (index != -1) this.UnitFsm.SwitchToNextState(this, this.GetCurrentState().slice(0, index)); this.Stop(false); } this.workOrders = []; let cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); if (cmpTrader) cmpTrader.StopTrading(); this.SetStance(this.template.DefaultStance); if (this.IsTurret()) this.SetTurretStance(); } }; UnitAI.prototype.OnDestroy = function() { // Switch to an empty state to let states execute their leave handlers. this.UnitFsm.SwitchToNextState(this, ""); let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losRangeQuery) cmpRangeManager.DestroyActiveQuery(this.losRangeQuery); if (this.losHealRangeQuery) cmpRangeManager.DestroyActiveQuery(this.losHealRangeQuery); if (this.losAttackRangeQuery) cmpRangeManager.DestroyActiveQuery(this.losAttackRangeQuery); }; UnitAI.prototype.OnVisionRangeChanged = function(msg) { if (this.entity == msg.entity) this.SetupRangeQueries(); }; UnitAI.prototype.HasPickupOrder = function(entity) { return this.orderQueue.some(order => order.type == "PickupUnit" && order.data.target == entity); }; UnitAI.prototype.OnPickupRequested = function(msg) { if (this.HasPickupOrder(msg.entity)) return; this.PushOrderAfterForced("PickupUnit", { "target": msg.entity, "iid": msg.iid }); }; UnitAI.prototype.OnPickupCanceled = function(msg) { for (let i = 0; i < this.orderQueue.length; ++i) { if (this.orderQueue[i].type != "PickupUnit" || this.orderQueue[i].data.target != msg.entity) continue; if (i == 0) this.UnitFsm.ProcessMessage(this, { "type": "PickupCanceled", "data": msg }); else this.orderQueue.splice(i, 1); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); break; } }; /** * Wrapper function that sets up the LOS, healer and attack range queries. * This should be called whenever our ownership changes. */ UnitAI.prototype.SetupRangeQueries = function() { if (this.GetStance().respondFleeOnSight) this.SetupLOSRangeQuery(); if (this.IsHealer()) this.SetupHealRangeQuery(); if (Engine.QueryInterface(this.entity, IID_Attack)) this.SetupAttackRangeQuery(); }; UnitAI.prototype.UpdateRangeQueries = function() { let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losRangeQuery) this.SetupLOSRangeQuery(cmpRangeManager.IsActiveQueryEnabled(this.losRangeQuery)); if (this.losHealRangeQuery) this.SetupHealRangeQuery(cmpRangeManager.IsActiveQueryEnabled(this.losHealRangeQuery)); if (this.losAttackRangeQuery) this.SetupAttackRangeQuery(cmpRangeManager.IsActiveQueryEnabled(this.losAttackRangeQuery)); }; /** * Set up a range query for all enemy units within LOS range. * @param {boolean} enable - Optional parameter whether to enable the query. */ UnitAI.prototype.SetupLOSRangeQuery = function(enable = true) { let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losRangeQuery) { cmpRangeManager.DestroyActiveQuery(this.losRangeQuery); this.losRangeQuery = undefined; } let cmpPlayer = QueryOwnerInterface(this.entity); // If we are being destructed (owner == -1), creating a range query is pointless. if (!cmpPlayer) return; let players = cmpPlayer.GetEnemies(); if (!players.length) return; let range = this.GetQueryRange(IID_Vision); // Do not compensate for entity sizes: LOS doesn't, and UnitAI relies on that. this.losRangeQuery = cmpRangeManager.CreateActiveQuery(this.entity, range.min, range.max, players, IID_Identity, cmpRangeManager.GetEntityFlagMask("normal"), false); if (enable) cmpRangeManager.EnableActiveQuery(this.losRangeQuery); }; /** * Set up a range query for all own or ally units within LOS range * which can be healed. * @param {boolean} enable - Optional parameter whether to enable the query. */ UnitAI.prototype.SetupHealRangeQuery = function(enable = true) { let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losHealRangeQuery) { cmpRangeManager.DestroyActiveQuery(this.losHealRangeQuery); this.losHealRangeQuery = undefined; } let cmpPlayer = QueryOwnerInterface(this.entity); // If we are being destructed (owner == -1), creating a range query is pointless. if (!cmpPlayer) return; let players = cmpPlayer.GetAllies(); let range = this.GetQueryRange(IID_Heal); // Do not compensate for entity sizes: LOS doesn't, and UnitAI relies on that. this.losHealRangeQuery = cmpRangeManager.CreateActiveQuery(this.entity, range.min, range.max, players, IID_Health, cmpRangeManager.GetEntityFlagMask("injured"), false); if (enable) cmpRangeManager.EnableActiveQuery(this.losHealRangeQuery); }; /** * Set up a range query for all enemy and gaia units within range * which can be attacked. * @param {boolean} enable - Optional parameter whether to enable the query. */ UnitAI.prototype.SetupAttackRangeQuery = function(enable = true) { let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losAttackRangeQuery) { cmpRangeManager.DestroyActiveQuery(this.losAttackRangeQuery); this.losAttackRangeQuery = undefined; } let cmpPlayer = QueryOwnerInterface(this.entity); // If we are being destructed (owner == -1), creating a range query is pointless. if (!cmpPlayer) return; // TODO: How to handle neutral players - Special query to attack military only? let players = cmpPlayer.GetEnemies(); if (!players.length) return; let range = this.GetQueryRange(IID_Attack); // Do not compensate for entity sizes: LOS doesn't, and UnitAI relies on that. this.losAttackRangeQuery = cmpRangeManager.CreateActiveQuery(this.entity, range.min, range.max, players, IID_Resistance, cmpRangeManager.GetEntityFlagMask("normal"), false); if (enable) cmpRangeManager.EnableActiveQuery(this.losAttackRangeQuery); }; // FSM linkage functions // Setting the next state to the current state will leave/re-enter the top-most substate. // Must be called from inside the FSM. UnitAI.prototype.SetNextState = function(state) { this.UnitFsm.SetNextState(this, state); }; // Must be called from inside the FSM. UnitAI.prototype.DeferMessage = function(msg) { this.UnitFsm.DeferMessage(this, msg); }; UnitAI.prototype.GetCurrentState = function() { return this.UnitFsm.GetCurrentState(this); }; UnitAI.prototype.FsmStateNameChanged = function(state) { Engine.PostMessage(this.entity, MT_UnitAIStateChanged, { "to": state }); }; /** * Call when the current order has been completed (or failed). * Removes the current order from the queue, and processes the * next one (if any). Returns false and defaults to IDLE * if there are no remaining orders or if the unit is not * inWorld and not garrisoned (thus usually waiting to be destroyed). * Must be called from inside the FSM. */ UnitAI.prototype.FinishOrder = function() { if (!this.orderQueue.length) { let stack = new Error().stack.trimRight().replace(/^/mg, ' '); // indent each line let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); let template = cmpTemplateManager.GetCurrentTemplateName(this.entity); error("FinishOrder called for entity " + this.entity + " (" + template + ") when order queue is empty\n" + stack); } this.orderQueue.shift(); this.order = this.orderQueue[0]; if (this.orderQueue.length && (this.isGarrisoned || this.IsFormationController() || Engine.QueryInterface(this.entity, IID_Position)?.IsInWorld())) { let ret = this.UnitFsm.ProcessMessage(this, { "type": "Order."+this.order.type, "data": this.order.data }); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); return ret; } this.orderQueue = []; this.order = undefined; // Switch to IDLE as a default state. this.SetNextState("IDLE"); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); // Check if there are queued formation orders if (this.IsFormationMember()) { this.SetNextState("FORMATIONMEMBER.IDLE"); let cmpUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); if (cmpUnitAI) { // Inform the formation controller that we finished this task Engine.QueryInterface(this.formationController, IID_Formation). SetFinishedEntity(this.entity); // We don't want to carry out the default order // if there are still queued formation orders left if (cmpUnitAI.GetOrders().length > 1) return true; } } return false; }; /** * Add an order onto the back of the queue, * and execute it if we didn't already have an order. */ UnitAI.prototype.PushOrder = function(type, data) { var order = { "type": type, "data": data }; this.orderQueue.push(order); if (this.orderQueue.length == 1) { this.order = order; this.UnitFsm.ProcessMessage(this, { "type": "Order."+this.order.type, "data": this.order.data }); } Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); }; /** * Add an order onto the front of the queue, * and execute it immediately. */ UnitAI.prototype.PushOrderFront = function(type, data, ignorePacking = false) { var order = { "type": type, "data": data }; // If current order is packing/unpacking then add new order after it. if (!ignorePacking && this.order && this.IsPacking()) { var packingOrder = this.orderQueue.shift(); this.orderQueue.unshift(packingOrder, order); } else { this.orderQueue.unshift(order); this.order = order; this.UnitFsm.ProcessMessage(this, { "type": "Order."+this.order.type, "data": this.order.data }); } Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); }; /** * Insert an order after the last forced order onto the queue * and after the other orders of the same type */ UnitAI.prototype.PushOrderAfterForced = function(type, data) { if (!this.order || ((!this.order.data || !this.order.data.force) && this.order.type != type)) this.PushOrderFront(type, data); else { for (let i = 1; i < this.orderQueue.length; ++i) { if (this.orderQueue[i].data && this.orderQueue[i].data.force) continue; if (this.orderQueue[i].type == type) continue; this.orderQueue.splice(i, 0, { "type": type, "data": data }); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); return; } this.PushOrder(type, data); } Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); }; /** * For a unit that is packing and trying to attack something, * either cancel packing or continue with packing, as appropriate. * Precondition: if the unit is packing/unpacking, then orderQueue * should have the Attack order at index 0, * and the Pack/Unpack order at index 1. * This precondition holds because if we are packing while processing "Order.Attack", * then we must have come from ReplaceOrder, which guarantees it. * * @param {boolean} requirePacked - true if the unit needs to be packed to continue attacking, * false if it needs to be unpacked. * @return {boolean} true if the unit can attack now, false if it must continue packing (or unpacking) first. */ UnitAI.prototype.EnsureCorrectPackStateForAttack = function(requirePacked) { let cmpPack = Engine.QueryInterface(this.entity, IID_Pack); if (!cmpPack || !cmpPack.IsPacking() || this.orderQueue.length != 2 || this.orderQueue[0].type != "Attack" || this.orderQueue[1].type != "Pack" && this.orderQueue[1].type != "Unpack") return true; if (cmpPack.IsPacked() == requirePacked) { // The unit is already in the packed/unpacked state we want. // Delete the packing order. this.orderQueue.splice(1, 1); cmpPack.CancelPack(); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); // Continue with the attack order. return true; } // Move the attack order behind the unpacking order, to continue unpacking. let tmp = this.orderQueue[0]; this.orderQueue[0] = this.orderQueue[1]; this.orderQueue[1] = tmp; Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); return false; }; UnitAI.prototype.WillMoveFromFoundation = function(target, checkPacking = true) { let cmpUnitAI = Engine.QueryInterface(target, IID_UnitAI); if (!IsOwnedByAllyOfEntity(this.entity, target) && cmpUnitAI && !cmpUnitAI.IsAnimal() && !Engine.QueryInterface(SYSTEM_ENTITY, IID_CeasefireManager).IsCeasefireActive() || checkPacking && this.IsPacking() || this.CanPack() || !this.AbleToMove()) return false; return !this.CheckTargetRangeExplicit(target, g_LeaveFoundationRange, -1); }; UnitAI.prototype.ReplaceOrder = function(type, data) { // Remember the previous work orders to be able to go back to them later if required if (data && data.force) { if (this.IsFormationController()) this.CallMemberFunction("UpdateWorkOrders", [type]); else this.UpdateWorkOrders(type); } // Do not replace packing/unpacking unless it is cancel order. // TODO: maybe a better way of doing this would be to use priority levels if (this.IsPacking() && type != "CancelPack" && type != "CancelUnpack" && type != "Stop") { var order = { "type": type, "data": data }; var packingOrder = this.orderQueue.shift(); if (type == "Attack") { // The Attack order is able to handle a packing unit, while other orders can't. this.orderQueue = [packingOrder]; this.PushOrderFront(type, data, true); } else if (packingOrder.type == "Unpack" && g_OrdersCancelUnpacking.has(type)) { // Immediately cancel unpacking before processing an order that demands a packed unit. let cmpPack = Engine.QueryInterface(this.entity, IID_Pack); cmpPack.CancelPack(); this.orderQueue = []; this.PushOrder(type, data); } else this.orderQueue = [packingOrder, order]; } else if (this.IsFormationMember()) { // Don't replace orders after a LeaveFormation order // (this is needed to support queued no-formation orders). let idx = this.orderQueue.findIndex(o => o.type == "LeaveFormation"); if (idx === -1) { this.orderQueue = []; this.order = undefined; } else this.orderQueue.splice(0, idx); this.PushOrderFront(type, data); } else { this.orderQueue = []; this.PushOrder(type, data); } Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); }; UnitAI.prototype.GetOrders = function() { return this.orderQueue.slice(); }; UnitAI.prototype.AddOrders = function(orders) { orders.forEach(order => this.PushOrder(order.type, order.data)); }; UnitAI.prototype.GetOrderData = function() { var orders = []; for (let order of this.orderQueue) if (order.data) orders.push(clone(order.data)); return orders; }; UnitAI.prototype.UpdateWorkOrders = function(type) { var isWorkType = type => type == "Gather" || type == "Trade" || type == "Repair" || type == "ReturnResource"; if (isWorkType(type)) { this.workOrders = []; return; } if (this.workOrders.length) return; if (this.IsFormationMember()) { var cmpUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); if (cmpUnitAI) { for (var i = 0; i < cmpUnitAI.orderQueue.length; ++i) { if (isWorkType(cmpUnitAI.orderQueue[i].type)) { this.workOrders = cmpUnitAI.orderQueue.slice(i); return; } } } } // If nothing found, take the unit orders for (var i = 0; i < this.orderQueue.length; ++i) { if (isWorkType(this.orderQueue[i].type)) { this.workOrders = this.orderQueue.slice(i); return; } } }; UnitAI.prototype.BackToWork = function() { if (this.workOrders.length == 0) return false; if (this.isGarrisoned && !Engine.QueryInterface(this.entity, IID_Garrisonable)?.UnGarrison(false)) return false; const cmpTurretable = Engine.QueryInterface(this.entity, IID_Turretable); if (this.IsTurret(cmpTurretable) && !cmpTurretable.LeaveTurret()) return false; this.orderQueue = []; this.AddOrders(this.workOrders); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); if (this.IsFormationMember()) { var cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) cmpFormation.RemoveMembers([this.entity]); } this.workOrders = []; return true; }; UnitAI.prototype.HasWorkOrders = function() { return this.workOrders.length > 0; }; UnitAI.prototype.GetWorkOrders = function() { return this.workOrders; }; UnitAI.prototype.SetWorkOrders = function(orders) { this.workOrders = orders; }; UnitAI.prototype.TimerHandler = function(data, lateness) { // Reset the timer if (data.timerRepeat === undefined) this.timer = undefined; this.UnitFsm.ProcessMessage(this, { "type": "Timer", "data": data, "lateness": lateness }); }; /** * Set up the UnitAI timer to run after 'offset' msecs, and then * every 'repeat' msecs until StopTimer is called. A "Timer" message * will be sent each time the timer runs. */ UnitAI.prototype.StartTimer = function(offset, repeat) { if (this.timer) error("Called StartTimer when there's already an active timer"); var data = { "timerRepeat": repeat }; var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); if (repeat === undefined) this.timer = cmpTimer.SetTimeout(this.entity, IID_UnitAI, "TimerHandler", offset, data); else this.timer = cmpTimer.SetInterval(this.entity, IID_UnitAI, "TimerHandler", offset, repeat, data); }; /** * Stop the current UnitAI timer. */ UnitAI.prototype.StopTimer = function() { if (!this.timer) return; var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.CancelTimer(this.timer); this.timer = undefined; }; UnitAI.prototype.OnMotionUpdate = function(msg) { if (msg.veryObstructed) msg.obstructed = true; this.UnitFsm.ProcessMessage(this, Object.assign({ "type": "MovementUpdate" }, msg)); }; /** * Called directly by cmpFoundation and cmpRepairable to * inform builders that repairing has finished. * This not done by listening to a global message due to performance. */ UnitAI.prototype.ConstructionFinished = function(msg) { this.UnitFsm.ProcessMessage(this, { "type": "ConstructionFinished", "data": msg }); }; UnitAI.prototype.OnGlobalEntityRenamed = function(msg) { let changed = false; let currentOrderChanged = false; for (let i = 0; i < this.orderQueue.length; ++i) { let order = this.orderQueue[i]; if (order.data && order.data.target && order.data.target == msg.entity) { changed = true; if (i == 0) currentOrderChanged = true; order.data.target = msg.newentity; } if (order.data && order.data.formationTarget && order.data.formationTarget == msg.entity) { changed = true; if (i == 0) currentOrderChanged = true; order.data.formationTarget = msg.newentity; } } if (!changed) return; if (currentOrderChanged) this.UnitFsm.ProcessMessage(this, { "type": "OrderTargetRenamed", "data": msg }); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); }; UnitAI.prototype.OnAttacked = function(msg) { if (msg.fromStatusEffect) return; this.UnitFsm.ProcessMessage(this, { "type": "Attacked", "data": msg }); }; UnitAI.prototype.OnGuardedAttacked = function(msg) { this.UnitFsm.ProcessMessage(this, { "type": "GuardedAttacked", "data": msg.data }); }; UnitAI.prototype.OnRangeUpdate = function(msg) { if (msg.tag == this.losRangeQuery) this.UnitFsm.ProcessMessage(this, { "type": "LosRangeUpdate", "data": msg }); else if (msg.tag == this.losHealRangeQuery) this.UnitFsm.ProcessMessage(this, { "type": "LosHealRangeUpdate", "data": msg }); else if (msg.tag == this.losAttackRangeQuery) this.UnitFsm.ProcessMessage(this, { "type": "LosAttackRangeUpdate", "data": msg }); }; UnitAI.prototype.OnPackFinished = function(msg) { this.UnitFsm.ProcessMessage(this, { "type": "PackFinished", "packed": msg.packed }); }; /** * A general function to process messages sent from components. * @param {string} type - The type of message to process. * @param {Object} msg - Optionally extra data to use. */ UnitAI.prototype.ProcessMessage = function(type, msg) { this.UnitFsm.ProcessMessage(this, { "type": type, "data": msg }); }; // Helper functions to be called by the FSM UnitAI.prototype.GetWalkSpeed = function() { let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (!cmpUnitMotion) return 0; return cmpUnitMotion.GetWalkSpeed(); }; UnitAI.prototype.GetRunMultiplier = function() { var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (!cmpUnitMotion) return 0; return cmpUnitMotion.GetRunMultiplier(); }; /** * Returns true if the target exists and has non-zero hitpoints. */ UnitAI.prototype.TargetIsAlive = function(ent) { var cmpFormation = Engine.QueryInterface(ent, IID_Formation); if (cmpFormation) return true; var cmpHealth = QueryMiragedInterface(ent, IID_Health); return cmpHealth && cmpHealth.GetHitpoints() != 0; }; /** * Returns true if the target exists and needs to be killed before * beginning to gather resources from it. */ UnitAI.prototype.MustKillGatherTarget = function(ent) { var cmpResourceSupply = Engine.QueryInterface(ent, IID_ResourceSupply); if (!cmpResourceSupply) return false; if (!cmpResourceSupply.GetKillBeforeGather()) return false; return this.TargetIsAlive(ent); }; /** * Returns the position of target or, if there is none, * the entity's position, or undefined. */ UnitAI.prototype.TargetPosOrEntPos = function(target) { let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); if (cmpTargetPosition && cmpTargetPosition.IsInWorld()) return cmpTargetPosition.GetPosition2D(); let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) return cmpPosition.GetPosition2D(); return undefined; }; /** * Returns the entity ID of the nearest resource supply where the given * filter returns true, or undefined if none can be found. * "Nearest" is nearest from @param position. * TODO: extend this to exclude resources that already have lots of gatherers. */ UnitAI.prototype.FindNearbyResource = function(position, filter) { if (!position) return undefined; // We accept resources owned by Gaia or any player let players = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetAllPlayers(); let range = 64; // TODO: what's a sensible number? let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); // Don't account for entity size, we need to match LOS visibility. let nearby = cmpRangeManager.ExecuteQueryAroundPos(position, 0, range, players, IID_ResourceSupply, false); return nearby.find(ent => { if (!this.CanGather(ent) || !this.CheckTargetVisible(ent)) return false; let template = cmpTemplateManager.GetCurrentTemplateName(ent); if (template.indexOf("resource|") != -1) template = template.slice(9); let cmpResourceSupply = Engine.QueryInterface(ent, IID_ResourceSupply); let type = cmpResourceSupply.GetType(); return cmpResourceSupply.IsAvailableTo(this.entity) && filter(ent, type, template); }); }; /** * Returns the entity ID of the nearest resource dropsite that accepts * the given type, or undefined if none can be found. */ UnitAI.prototype.FindNearestDropsite = function(genericType) { let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || cmpOwnership.GetOwner() == INVALID_PLAYER) return undefined; let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return undefined; let pos = cmpPosition.GetPosition2D(); let bestDropsite; let bestDist = Infinity; // Maximum distance a point on an obstruction can be from the center of the obstruction. let maxDifference = 40; let owner = cmpOwnership.GetOwner(); let cmpPlayer = QueryOwnerInterface(this.entity); let players = cmpPlayer && cmpPlayer.HasSharedDropsites() ? cmpPlayer.GetMutualAllies() : [owner]; let nearestDropsites = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).ExecuteQuery(this.entity, 0, -1, players, IID_ResourceDropsite, false); let isShip = Engine.QueryInterface(this.entity, IID_Identity).HasClass("Ship"); let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); for (let dropsite of nearestDropsites) { // Ships are unable to reach land dropsites and shouldn't attempt to do so. if (isShip && !Engine.QueryInterface(dropsite, IID_Identity).HasClass("Naval")) continue; let cmpResourceDropsite = Engine.QueryInterface(dropsite, IID_ResourceDropsite); if (!cmpResourceDropsite.AcceptsType(genericType) || !this.CheckTargetVisible(dropsite)) continue; if (Engine.QueryInterface(dropsite, IID_Ownership).GetOwner() != owner && !cmpResourceDropsite.IsShared()) continue; // The range manager sorts entities by the distance to their center, // but we want the distance to the point where resources will be dropped off. let dist = cmpObstructionManager.DistanceToPoint(dropsite, pos.x, pos.y); if (dist == -1) continue; if (dist < bestDist) { bestDropsite = dropsite; bestDist = dist; } else if (dist > bestDist + maxDifference) break; } return bestDropsite; }; /** * Returns the entity ID of the nearest building that needs to be constructed. * "Nearest" is nearest from @param position. */ UnitAI.prototype.FindNearbyFoundation = function(position) { if (!position) return undefined; let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || cmpOwnership.GetOwner() == INVALID_PLAYER) return undefined; let players = [cmpOwnership.GetOwner()]; let range = 64; // TODO: what's a sensible number? let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); // Don't account for entity size, we need to match LOS visibility. let nearby = cmpRangeManager.ExecuteQueryAroundPos(position, 0, range, players, IID_Foundation, false); // Skip foundations that are already complete. (This matters since // we process the ConstructionFinished message before the foundation // we're working on has been deleted.) return nearby.find(ent => !Engine.QueryInterface(ent, IID_Foundation).IsFinished() && this.CheckTargetVisible(ent)); }; /** * Returns the entity ID of the nearest treasure. * "Nearest" is nearest from @param position. */ UnitAI.prototype.FindNearbyTreasure = function(position) { if (!position) return undefined; let cmpTreasureCollector = Engine.QueryInterface(this.entity, IID_TreasureCollector); if (!cmpTreasureCollector) return undefined; let players = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetAllPlayers(); let range = 64; // TODO: what's a sensible number? let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); // Don't account for entity size, we need to match LOS visibility. let nearby = cmpRangeManager.ExecuteQueryAroundPos(position, 0, range, players, IID_Treasure, false); return nearby.find(ent => cmpTreasureCollector.CanCollect(ent) && this.CheckTargetVisible(ent)); }; /** * Play a sound appropriate to the current entity. */ UnitAI.prototype.PlaySound = function(name) { if (this.IsFormationController()) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); var member = cmpFormation.GetPrimaryMember(); if (member) PlaySound(name, member); } else { PlaySound(name, this.entity); } }; /* * Set a visualActor animation variant. * By changing the animation variant, you can change animations based on unitAI state. * If there are no specific variants or the variant doesn't exist in the actor, * the actor fallbacks to any existing animation. * @param type if present, switch to a specific animation variant. */ UnitAI.prototype.SetAnimationVariant = function(type) { let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (!cmpVisual) return; cmpVisual.SetVariant("animationVariant", type); }; /* * Reset the animation variant to default behavior. * Default behavior is to pick a resource-carrying variant if resources are being carried. * Otherwise pick nothing in particular. */ UnitAI.prototype.SetDefaultAnimationVariant = function() { let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (cmpResourceGatherer) { let type = cmpResourceGatherer.GetLastCarriedType(); if (type) { let typename = "carry_" + type.generic; if (type.specific == "meat") typename = "carry_" + type.specific; this.SetAnimationVariant(typename); return; } } this.SetAnimationVariant(""); }; UnitAI.prototype.ResetAnimation = function() { let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (!cmpVisual) return; cmpVisual.SelectAnimation("idle", false, 1.0); }; UnitAI.prototype.SelectAnimation = function(name, once = false, speed = 1.0) { let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (!cmpVisual) return; cmpVisual.SelectAnimation(name, once, speed); }; UnitAI.prototype.SetAnimationSync = function(actiontime, repeattime) { var cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (!cmpVisual) return; cmpVisual.SetAnimationSyncRepeat(repeattime); cmpVisual.SetAnimationSyncOffset(actiontime); }; UnitAI.prototype.StopMoving = function() { let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion) cmpUnitMotion.StopMoving(); }; /** * Generic dispatcher for other MoveTo functions. * @param iid - Interface ID (optional) implementing GetRange * @param type - Range type for the interface call * @returns whether the move succeeded or failed. */ UnitAI.prototype.MoveTo = function(data, iid, type) { if (data.target) { if (data.min || data.max) return this.MoveToTargetRangeExplicit(data.target, data.min || -1, data.max || -1); else if (!iid) return this.MoveToTarget(data.target); return this.MoveToTargetRange(data.target, iid, type); } else if (data.min || data.max) return this.MoveToPointRange(data.x, data.z, data.min || -1, data.max || -1); return this.MoveToPoint(data.x, data.z); }; UnitAI.prototype.MoveToPoint = function(x, z) { let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return this.AbleToMove(cmpUnitMotion) && cmpUnitMotion.MoveToPointRange(x, z, 0, 0); // For point goals, allow a max range of 0. }; UnitAI.prototype.MoveToPointRange = function(x, z, rangeMin, rangeMax) { let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return this.AbleToMove(cmpUnitMotion) && cmpUnitMotion.MoveToPointRange(x, z, rangeMin, rangeMax); }; UnitAI.prototype.MoveToTarget = function(target) { if (!this.CheckTargetVisible(target)) return false; let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return this.AbleToMove(cmpUnitMotion) && cmpUnitMotion.MoveToTargetRange(target, 0, 1); }; UnitAI.prototype.MoveToTargetRange = function(target, iid, type) { if (!this.CheckTargetVisible(target)) return false; let range = this.GetRange(iid, type, target); if (!range) return false; let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return this.AbleToMove(cmpUnitMotion) && cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); }; /** * 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 */ UnitAI.prototype.MoveToTargetAttackRange = 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 false; } - let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); + const cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (!this.AbleToMove(cmpUnitMotion)) return false; - let cmpFormation = Engine.QueryInterface(target, IID_Formation); + const cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) target = cmpFormation.GetClosestMember(this.entity); if (type != "Ranged") return this.MoveToTargetRange(target, IID_Attack, type); if (!this.CheckTargetVisible(target)) return false; const cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return false; const range = cmpAttack.GetRange(type); - let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); - if (!thisCmpPosition.IsInWorld()) - return false; - let s = thisCmpPosition.GetPosition(); - - let targetCmpPosition = Engine.QueryInterface(target, IID_Position); - if (!targetCmpPosition || !targetCmpPosition.IsInWorld()) - return false; - - // Parabolic range compuation is the same as in BuildingAI's FireArrows. - let t = targetCmpPosition.GetPosition(); - // h is positive when I'm higher than the target - const h = s.y - t.y + cmpAttack.GetAttackYOrigin(type); - - let parabolicMaxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h); - // No negative roots please - if (h <= -range.max / 2) - // return false? Or hope you come close enough? - parabolicMaxRange = 0; + // In case the range returns negative, we are probably too high compared to the target. Hope we come close enough. + const parabolicMaxRange = Math.max(0, Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).GetEffectiveParabolicRange(this.entity, target, range.max, cmpAttack.GetAttackYOrigin(type))); // The parabole changes while walking so be cautious: - let guessedMaxRange = parabolicMaxRange > range.max ? (range.max + parabolicMaxRange) / 2 : parabolicMaxRange; + const guessedMaxRange = parabolicMaxRange > range.max ? (range.max + parabolicMaxRange) / 2 : parabolicMaxRange; return cmpUnitMotion && cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange); }; UnitAI.prototype.MoveToTargetRangeExplicit = function(target, min, max) { if (!this.CheckTargetVisible(target)) return false; let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return this.AbleToMove(cmpUnitMotion) && cmpUnitMotion.MoveToTargetRange(target, min, max); }; /** * Move unit so we hope the target is in the attack range of the formation. * * @param {number} target - The target entity ID to attack. * @return {boolean} - Whether the order to move has succeeded. */ UnitAI.prototype.MoveFormationToTargetAttackRange = function(target) { let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation); if (cmpTargetFormation) target = cmpTargetFormation.GetClosestMember(this.entity); if (!this.CheckTargetVisible(target)) 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); return this.AbleToMove(cmpUnitMotion) && cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); }; /** * Generic dispatcher for other Check...Range functions. * @param iid - Interface ID (optional) implementing GetRange * @param type - Range type for the interface call */ UnitAI.prototype.CheckRange = function(data, iid, type) { if (data.target) { if (data.min || data.max) return this.CheckTargetRangeExplicit(data.target, data.min || -1, data.max || -1); else if (!iid) return this.CheckTargetRangeExplicit(data.target, 0, 1); return this.CheckTargetRange(data.target, iid, type); } else if (data.min || data.max) return this.CheckPointRangeExplicit(data.x, data.z, data.min || -1, data.max || -1); return this.CheckPointRangeExplicit(data.x, data.z, 0, 0); }; UnitAI.prototype.CheckPointRangeExplicit = function(x, z, min, max) { let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); return cmpObstructionManager.IsInPointRange(this.entity, x, z, min, max, false); }; UnitAI.prototype.CheckTargetRange = function(target, iid, type) { let range = this.GetRange(iid, type, target); if (!range) return false; let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); return cmpObstructionManager.IsInTargetRange(this.entity, target, 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 * 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.CheckTargetAttackRange = 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() && cmpFormationUnitAI.order.data.target == target) return true; } let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) target = cmpFormation.GetClosestMember(this.entity); let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); return cmpAttack && cmpAttack.IsTargetInRange(target, type); }; UnitAI.prototype.CheckTargetRangeExplicit = function(target, min, max) { let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); return cmpObstructionManager.IsInTargetRange(this.entity, target, min, max, false); }; /** * Check if the target is inside the attack range of the formation. * * @param {number} target - The target entity ID to attack. * @return {boolean} - Whether the entity is within attacking distance. */ UnitAI.prototype.CheckFormationTargetAttackRange = function(target) { 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 cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false); }; /** * Returns true if the target entity is visible through the FoW/SoD. */ UnitAI.prototype.CheckTargetVisible = function(target) { if (this.isGarrisoned) return false; const cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership) return false; const cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (!cmpRangeManager) return false; // Entities that are hidden and miraged are considered visible const cmpFogging = Engine.QueryInterface(target, IID_Fogging); if (cmpFogging && cmpFogging.IsMiraged(cmpOwnership.GetOwner())) return true; if (cmpRangeManager.GetLosVisibility(target, cmpOwnership.GetOwner()) == "hidden") return false; // Either visible directly, or visible in fog return true; }; /** * Returns true if the given position is currentl visible (not in FoW/SoD). */ UnitAI.prototype.CheckPositionVisible = function(x, z) { let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership) return false; let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (!cmpRangeManager) return false; return cmpRangeManager.GetLosVisibilityPosition(x, z, cmpOwnership.GetOwner()) == "visible"; }; /** * How close to our goal do we consider it's OK to stop if the goal appears unreachable. * Currently 3 terrain tiles as that's relatively close but helps pathfinding. */ UnitAI.prototype.DefaultRelaxedMaxRange = 12; /** * @returns true if the unit is in the relaxed-range from the target. */ UnitAI.prototype.RelaxedMaxRangeCheck = function(data, relaxedRange) { if (!data.relaxed) return false; let ndata = data; ndata.min = 0; ndata.max = relaxedRange; return this.CheckRange(ndata); }; /** * Let an entity face its target. * @param {number} target - The entity-ID of the target. */ UnitAI.prototype.FaceTowardsTarget = function(target) { let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) 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) { cmpUnitMotion.FaceTowardsPoint(targetPosition.x, targetPosition.y); return; } let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) cmpPosition.TurnTo(cmpPosition.GetPosition2D().angleTo(targetPosition)); }; UnitAI.prototype.CheckTargetDistanceFromHeldPosition = function(target, iid, type) { let range = this.GetRange(iid, type, target); if (!range) return false; let cmpPosition = Engine.QueryInterface(target, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return false; let cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) return false; let halfvision = cmpVision.GetRange() / 2; let pos = cmpPosition.GetPosition(); let heldPosition = this.heldPosition; if (heldPosition === undefined) heldPosition = { "x": pos.x, "z": pos.z }; return Math.euclidDistance2D(pos.x, pos.z, heldPosition.x, heldPosition.z) < halfvision + range.max; }; UnitAI.prototype.CheckTargetIsInVisionRange = function(target) { let cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) return false; let range = cmpVision.GetRange(); let distance = PositionHelper.DistanceBetweenEntities(this.entity, target); return distance < range; }; UnitAI.prototype.GetBestAttackAgainst = function(target, allowCapture) { var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return undefined; return cmpAttack.GetBestAttackAgainst(target, allowCapture); }; /** * Try to find one of the given entities which can be attacked, * and start attacking it. * Returns true if it found something to attack. */ UnitAI.prototype.AttackVisibleEntity = function(ents) { var target = ents.find(target => this.CanAttack(target)); if (!target) return false; this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": true }); return true; }; /** * Try to find one of the given entities which can be attacked * and which is close to the hold position, and start attacking it. * Returns true if it found something to attack. */ UnitAI.prototype.AttackEntityInZone = function(ents) { var target = ents.find(target => this.CanAttack(target) && this.CheckTargetDistanceFromHeldPosition(target, IID_Attack, this.GetBestAttackAgainst(target, true)) && (this.GetStance().respondChaseBeyondVision || this.CheckTargetIsInVisionRange(target)) ); if (!target) return false; this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": true }); return true; }; /** * Try to respond appropriately given our current stance, * given a list of entities that match our stance's target criteria. * Returns true if it responded. */ UnitAI.prototype.RespondToTargetedEntities = function(ents) { if (!ents.length) return false; if (this.GetStance().respondChase) return this.AttackVisibleEntity(ents); if (this.GetStance().respondStandGround) return this.AttackVisibleEntity(ents); if (this.GetStance().respondHoldGround) return this.AttackEntityInZone(ents); if (this.GetStance().respondFlee) { if (this.order && this.order.type == "Flee") this.orderQueue.shift(); this.PushOrderFront("Flee", { "target": ents[0], "force": false }); return true; } return false; }; /** * @param {number} ents - An array of the IDs of the spotted entities. * @return {boolean} - Whether we responded. */ UnitAI.prototype.RespondToSightedEntities = function(ents) { if (!ents || !ents.length) return false; if (this.GetStance().respondFleeOnSight) { this.Flee(ents[0], false); return true; } return false; }; /** * Try to respond to healable entities. * Returns true if it responded. */ UnitAI.prototype.RespondToHealableEntities = function(ents) { let ent = ents.find(ent => this.CanHeal(ent)); if (!ent) return false; this.PushOrderFront("Heal", { "target": ent, "force": false }); return true; }; /** * Returns true if we should stop following the target entity. */ UnitAI.prototype.ShouldAbandonChase = function(target, force, iid, type) { if (!this.CheckTargetVisible(target)) return true; // Forced orders shouldn't be interrupted. if (force) return false; // If we are guarding/escorting, don't abandon as long as the guarded unit is in target range of the attacker if (this.isGuardOf) { let cmpUnitAI = Engine.QueryInterface(target, IID_UnitAI); let cmpAttack = Engine.QueryInterface(target, IID_Attack); if (cmpUnitAI && cmpAttack && cmpAttack.GetAttackTypes().some(type => cmpUnitAI.CheckTargetAttackRange(this.isGuardOf, type))) return false; } if (this.GetStance().respondHoldGround) if (!this.CheckTargetDistanceFromHeldPosition(target, iid, type)) return true; // Stop if it's left our vision range, unless we're especially persistent. if (!this.GetStance().respondChaseBeyondVision) if (!this.CheckTargetIsInVisionRange(target)) return true; return false; }; /* * Returns whether we should chase the targeted entity, * given our current stance. */ UnitAI.prototype.ShouldChaseTargetedEntity = function(target, force) { if (!this.AbleToMove()) return false; if (this.GetStance().respondChase) return true; // If we are guarding/escorting, chase at least as long as the guarded unit is in target range of the attacker if (this.isGuardOf) { let cmpUnitAI = Engine.QueryInterface(target, IID_UnitAI); let cmpAttack = Engine.QueryInterface(target, IID_Attack); if (cmpUnitAI && cmpAttack && cmpAttack.GetAttackTypes().some(type => cmpUnitAI.CheckTargetAttackRange(this.isGuardOf, type))) return true; } return force; }; // External interface functions /** * Order a unit to leave the formation it is in. * Used to handle queued no-formation orders for units in formation. */ UnitAI.prototype.LeaveFormation = function(queued = true) { // If queued, add the order even if we're not in formation, // maybe we will be later. if (!queued && !this.IsFormationMember()) return; if (queued) this.AddOrder("LeaveFormation", { "force": true }, queued); else this.PushOrderFront("LeaveFormation", { "force": true }); }; UnitAI.prototype.SetFormationController = function(ent) { this.formationController = ent; // Set obstruction group, so we can walk through members // of our own formation (or ourself if not in formation) const cmpObstruction = Engine.QueryInterface(this.entity, IID_Obstruction); if (cmpObstruction) { if (ent == INVALID_ENTITY) cmpObstruction.SetControlGroup(this.entity); else cmpObstruction.SetControlGroup(ent); } const cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion) cmpUnitMotion.SetMemberOfFormation(ent); // If we were removed from a formation, let the FSM switch back to INDIVIDUAL if (ent == INVALID_ENTITY) this.UnitFsm.ProcessMessage(this, { "type": "FormationLeave" }); }; UnitAI.prototype.GetFormationController = function() { return this.formationController; }; UnitAI.prototype.GetFormationTemplate = function() { return Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager).GetCurrentTemplateName(this.formationController) || NULL_FORMATION; }; UnitAI.prototype.MoveIntoFormation = function(cmd) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (!cmpFormation) return; var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return; var pos = cmpPosition.GetPosition(); this.PushOrderFront("MoveIntoFormation", { "x": pos.x, "z": pos.z, "force": true }); }; UnitAI.prototype.GetTargetPositions = function() { var targetPositions = []; for (var i = 0; i < this.orderQueue.length; ++i) { var order = this.orderQueue[i]; switch (order.type) { case "Walk": case "WalkAndFight": case "WalkToPointRange": case "MoveIntoFormation": case "GatherNearPosition": case "Patrol": targetPositions.push(new Vector2D(order.data.x, order.data.z)); break; // and continue the loop case "WalkToTarget": case "WalkToTargetRange": // This doesn't move to the target (just into range), but a later order will. case "Guard": case "Flee": case "LeaveFoundation": case "Attack": case "Heal": case "Gather": case "ReturnResource": case "Repair": case "Garrison": case "CollectTreasure": var cmpTargetPosition = Engine.QueryInterface(order.data.target, IID_Position); if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) return targetPositions; targetPositions.push(cmpTargetPosition.GetPosition2D()); return targetPositions; case "Stop": return []; case "DropAtNearestDropSite": break; default: error("GetTargetPositions: Unrecognised order type '"+order.type+"'"); return []; } } return targetPositions; }; /** * Returns the estimated distance that this unit will travel before either * finishing all of its orders, or reaching a non-walk target (attack, gather, etc). * Intended for Formation to switch to column layout on long walks. */ UnitAI.prototype.ComputeWalkingDistance = function() { var distance = 0; var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return 0; // Keep track of the position at the start of each order var pos = cmpPosition.GetPosition2D(); var targetPositions = this.GetTargetPositions(); for (var i = 0; i < targetPositions.length; ++i) { distance += pos.distanceTo(targetPositions[i]); // Remember this as the start position for the next order pos = targetPositions[i]; } return distance; }; UnitAI.prototype.AddOrder = function(type, data, queued, pushFront) { if (this.expectedRoute) this.expectedRoute = undefined; if (pushFront) this.PushOrderFront(type, data); else if (queued) this.PushOrder(type, data); else this.ReplaceOrder(type, data); }; /** * Adds guard/escort order to the queue, forced by the player. */ UnitAI.prototype.Guard = function(target, queued, pushFront) { if (!this.CanGuard()) { this.WalkToTarget(target, queued); return; } if (target === this.entity) return; if (this.isGuardOf) { if (this.isGuardOf == target && this.order && this.order.type == "Guard") return; this.RemoveGuard(); } this.AddOrder("Guard", { "target": target, "force": false }, queued, pushFront); }; /** * @return {boolean} - Whether it makes sense to guard the given entity. */ UnitAI.prototype.ShouldGuard = function(target) { return this.TargetIsAlive(target) || Engine.QueryInterface(target, IID_Capturable) || Engine.QueryInterface(target, IID_StatusEffectsReceiver); }; UnitAI.prototype.AddGuard = function(target) { if (!this.CanGuard()) return false; var cmpGuard = Engine.QueryInterface(target, IID_Guard); if (!cmpGuard) return false; this.isGuardOf = target; this.guardRange = cmpGuard.GetRange(this.entity); cmpGuard.AddGuard(this.entity); return true; }; UnitAI.prototype.RemoveGuard = function() { if (!this.isGuardOf) return; let cmpGuard = Engine.QueryInterface(this.isGuardOf, IID_Guard); if (cmpGuard) cmpGuard.RemoveGuard(this.entity); this.guardRange = undefined; this.isGuardOf = undefined; if (!this.order) return; if (this.order.type == "Guard") this.UnitFsm.ProcessMessage(this, { "type": "RemoveGuard" }); else for (let i = 1; i < this.orderQueue.length; ++i) if (this.orderQueue[i].type == "Guard") this.orderQueue.splice(i, 1); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); }; UnitAI.prototype.IsGuardOf = function() { return this.isGuardOf; }; UnitAI.prototype.SetGuardOf = function(entity) { // entity may be undefined this.isGuardOf = entity; }; UnitAI.prototype.CanGuard = function() { // Formation controllers should always respond to commands // (then the individual units can make up their own minds) if (this.IsFormationController()) return true; return this.template.CanGuard == "true"; }; UnitAI.prototype.CanPatrol = function() { // Formation controllers should always respond to commands // (then the individual units can make up their own minds) return this.IsFormationController() || this.template.CanPatrol == "true"; }; /** * Adds walk order to queue, forced by the player. */ UnitAI.prototype.Walk = function(x, z, queued, pushFront) { if (!pushFront && this.expectedRoute && queued) this.expectedRoute.push({ "x": x, "z": z }); else this.AddOrder("Walk", { "x": x, "z": z, "force": true }, queued, pushFront); }; /** * Adds walk to point range order to queue, forced by the player. */ UnitAI.prototype.WalkToPointRange = function(x, z, min, max, queued, pushFront) { this.AddOrder("Walk", { "x": x, "z": z, "min": min, "max": max, "force": true }, queued, pushFront); }; /** * Adds stop order to queue, forced by the player. */ UnitAI.prototype.Stop = function(queued, pushFront) { this.AddOrder("Stop", { "force": true }, queued, pushFront); }; /** * The unit will drop all resources at the closest dropsite. If this unit is no gatherer or * no dropsite is available, it will do nothing. */ UnitAI.prototype.DropAtNearestDropSite = function(queued, pushFront) { this.AddOrder("DropAtNearestDropSite", { "force": true }, queued, pushFront); }; /** * Adds walk-to-target order to queue, this only occurs in response * to a player order, and so is forced. */ UnitAI.prototype.WalkToTarget = function(target, queued, pushFront) { this.AddOrder("WalkToTarget", { "target": target, "force": true }, queued, pushFront); }; /** * Adds walk-and-fight order to queue, this only occurs in response * to a player order, and so is forced. * If targetClasses is given, only entities matching the targetClasses can be attacked. */ UnitAI.prototype.WalkAndFight = function(x, z, targetClasses, allowCapture = true, queued = false, pushFront = false) { this.AddOrder("WalkAndFight", { "x": x, "z": z, "targetClasses": targetClasses, "allowCapture": allowCapture, "force": true }, queued, pushFront); }; UnitAI.prototype.Patrol = function(x, z, targetClasses, allowCapture = true, queued = false, pushFront = false) { if (!this.CanPatrol()) { this.Walk(x, z, queued); return; } this.AddOrder("Patrol", { "x": x, "z": z, "targetClasses": targetClasses, "allowCapture": allowCapture, "force": true }, queued, pushFront); }; /** * Adds leave foundation order to queue, treated as forced. */ UnitAI.prototype.LeaveFoundation = function(target) { // If we're already being told to leave a foundation, then // ignore this new request so we don't end up being too indecisive // to ever actually move anywhere. if (this.order && (this.order.type == "LeaveFoundation" || (this.order.type == "Flee" && this.order.data.target == target))) return; if (this.orderQueue.length && this.orderQueue[0].type == "Unpack" && this.WillMoveFromFoundation(target, false)) { let cmpPack = Engine.QueryInterface(this.entity, IID_Pack); if (cmpPack) cmpPack.CancelPack(); } if (this.IsPacking()) return; this.PushOrderFront("LeaveFoundation", { "target": target, "force": true }); }; /** * Adds attack order to the queue, forced by the player. */ UnitAI.prototype.Attack = function(target, allowCapture = true, queued = false, pushFront = false) { if (!this.CanAttack(target)) { // We don't want to let healers walk to the target unit so they can be easily killed. // Instead we just let them get into healing range. if (this.IsHealer()) this.MoveToTargetRange(target, IID_Heal); else this.WalkToTarget(target, queued, pushFront); return; } let order = { "target": target, "force": true, "allowCapture": allowCapture, }; this.RememberTargetPosition(order); if (this.order && this.order.type == "Attack" && this.order.data && this.order.data.target === order.target && this.order.data.allowCapture === order.allowCapture) { this.order.data.lastPos = order.lastPos; this.order.data.force = order.force; if (order.force) this.orderQueue = [this.order]; return; } this.AddOrder("Attack", order, queued, pushFront); }; /** * Adds garrison order to the queue, forced by the player. */ UnitAI.prototype.Garrison = function(target, queued, pushFront) { // Not allowed to garrison when occupying a turret, at the moment. if (this.isGarrisoned || this.IsTurret()) return; if (target == this.entity) return; if (!this.CanGarrison(target)) { this.WalkToTarget(target, queued); return; } this.AddOrder("Garrison", { "target": target, "force": true, "garrison": true }, queued, pushFront); }; /** * Adds ungarrison order to the queue. */ UnitAI.prototype.Ungarrison = function() { if (!this.isGarrisoned && !this.IsTurret()) return; this.AddOrder("Ungarrison", null, false); }; /** * Adds garrison order to the queue, forced by the player. */ UnitAI.prototype.OccupyTurret = function(target, queued, pushFront) { if (target == this.entity) return; if (!this.CanOccupyTurret(target)) { this.WalkToTarget(target, queued); return; } this.AddOrder("Garrison", { "target": target, "force": true, "garrison": false }, queued, pushFront); }; /** * Adds gather order to the queue, forced by the player * until the target is reached */ UnitAI.prototype.Gather = function(target, queued, pushFront) { this.PerformGather(target, queued, true, pushFront); }; /** * Internal function to abstract the force parameter. */ UnitAI.prototype.PerformGather = function(target, queued, force, pushFront = false) { if (!this.CanGather(target)) { this.WalkToTarget(target, queued); return; } // Save the resource type now, so if the resource gets destroyed // before we process the order then we still know what resource // type to look for more of var type; var cmpResourceSupply = QueryMiragedInterface(target, IID_ResourceSupply); if (cmpResourceSupply) type = cmpResourceSupply.GetType(); else error("CanGather allowed gathering from invalid entity"); // Also save the target entity's template, so that if it's an animal, // we won't go from hunting slow safe animals to dangerous fast ones var cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); var template = cmpTemplateManager.GetCurrentTemplateName(target); if (template.indexOf("resource|") != -1) template = template.slice(9); let order = { "target": target, "type": type, "template": template, "force": force, }; this.RememberTargetPosition(order); order.initPos = order.lastPos; if (this.order && (this.order.type == "Gather" || this.order.type == "Attack") && this.order.data && this.order.data.target === order.target) { this.order.data.lastPos = order.lastPos; this.order.data.force = order.force; if (order.force) { if (this.orderQueue[1]?.type === "Gather") this.orderQueue = [this.order, this.orderQueue[1]]; else this.orderQueue = [this.order]; } return; } this.AddOrder("Gather", order, queued, pushFront); }; /** * Adds gather-near-position order to the queue, not forced, so it can be * interrupted by attacks. */ UnitAI.prototype.GatherNearPosition = function(x, z, type, template, queued, pushFront) { if (template.indexOf("resource|") != -1) template = template.slice(9); if (this.IsFormationController() || Engine.QueryInterface(this.entity, IID_ResourceGatherer)) this.AddOrder("GatherNearPosition", { "type": type, "template": template, "x": x, "z": z, "force": false }, queued, pushFront); else this.AddOrder("Walk", { "x": x, "z": z, "force": false }, queued, pushFront); }; /** * Adds heal order to the queue, forced by the player. */ UnitAI.prototype.Heal = function(target, queued, pushFront) { if (!this.CanHeal(target)) { this.WalkToTarget(target, queued); return; } if (this.order && this.order.type == "Heal" && this.order.data && this.order.data.target === target) { this.order.data.force = true; this.orderQueue = [this.order]; return; } this.AddOrder("Heal", { "target": target, "force": true }, queued, pushFront); }; /** * Adds return resource order to the queue, forced by the player. */ UnitAI.prototype.ReturnResource = function(target, queued, pushFront) { if (!this.CanReturnResource(target, true)) { this.WalkToTarget(target, queued); return; } this.AddOrder("ReturnResource", { "target": target, "force": true }, queued, pushFront); }; /** * Adds order to collect a treasure to queue, forced by the player. */ UnitAI.prototype.CollectTreasure = function(target, queued, pushFront) { this.AddOrder("CollectTreasure", { "target": target, "force": true }, queued, pushFront); }; /** * Adds order to collect a treasure to queue, forced by the player. */ UnitAI.prototype.CollectTreasureNearPosition = function(posX, posZ, queued, pushFront) { this.AddOrder("CollectTreasureNearPosition", { "x": posX, "z": posZ, "force": true }, queued, pushFront); }; UnitAI.prototype.CancelSetupTradeRoute = function(target) { let cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); if (!cmpTrader) return; cmpTrader.RemoveTargetMarket(target); if (this.IsFormationController()) this.CallMemberFunction("CancelSetupTradeRoute", [target]); }; /** * Adds trade order to the queue. Either walk to the first market, or * start a new route. Not forced, so it can be interrupted by attacks. * The possible route may be given directly as a SetupTradeRoute argument * if coming from a RallyPoint, or through this.expectedRoute if a user command. */ UnitAI.prototype.SetupTradeRoute = function(target, source, route, queued, pushFront) { if (!this.CanTrade(target)) { this.WalkToTarget(target, queued); return; } // AI has currently no access to BackToWork let cmpPlayer = QueryOwnerInterface(this.entity); if (cmpPlayer && cmpPlayer.IsAI() && !this.IsFormationController() && this.workOrders.length && this.workOrders[0].type == "Trade") { let cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); if (cmpTrader.HasBothMarkets() && (cmpTrader.GetFirstMarket() == target && cmpTrader.GetSecondMarket() == source || cmpTrader.GetFirstMarket() == source && cmpTrader.GetSecondMarket() == target)) { this.BackToWork(); return; } } var marketsChanged = this.SetTargetMarket(target, source); if (!marketsChanged) return; var cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); if (cmpTrader.HasBothMarkets()) { let data = { "target": cmpTrader.GetFirstMarket(), "route": route, "force": false }; if (this.expectedRoute) { if (!route && this.expectedRoute.length) data.route = this.expectedRoute.slice(); this.expectedRoute = undefined; } if (this.IsFormationController()) { this.CallMemberFunction("AddOrder", ["Trade", data, queued]); let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (cmpFormation) cmpFormation.Disband(); } else this.AddOrder("Trade", data, queued, pushFront); } else { if (this.IsFormationController()) this.CallMemberFunction("WalkToTarget", [cmpTrader.GetFirstMarket(), queued, pushFront]); else this.WalkToTarget(cmpTrader.GetFirstMarket(), queued, pushFront); this.expectedRoute = []; } }; UnitAI.prototype.SetTargetMarket = function(target, source) { var cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); if (!cmpTrader) return false; var marketsChanged = cmpTrader.SetTargetMarket(target, source); if (this.IsFormationController()) this.CallMemberFunction("SetTargetMarket", [target, source]); return marketsChanged; }; UnitAI.prototype.SwitchMarketOrder = function(oldMarket, newMarket) { if (this.order && this.order.data && this.order.data.target && this.order.data.target == oldMarket) this.order.data.target = newMarket; }; UnitAI.prototype.MoveToMarket = function(targetMarket) { let nextTarget; if (this.waypoints && this.waypoints.length >= 1) nextTarget = this.waypoints.pop(); else nextTarget = { "target": targetMarket }; this.order.data.nextTarget = nextTarget; return this.MoveTo(this.order.data.nextTarget, IID_Trader); }; UnitAI.prototype.MarketRemoved = function(market) { if (this.order && this.order.data && this.order.data.target && this.order.data.target == market) this.UnitFsm.ProcessMessage(this, { "type": "TradingCanceled", "market": market }); }; /** * Adds repair/build order to the queue, forced by the player * until the target is reached */ UnitAI.prototype.Repair = function(target, autocontinue, queued, pushFront) { if (!this.CanRepair(target)) { this.WalkToTarget(target, queued); return; } if (this.order && this.order.type == "Repair" && this.order.data && this.order.data.target === target && this.order.data.autocontinue === autocontinue) { this.order.data.force = true; this.orderQueue = [this.order]; return; } this.AddOrder("Repair", { "target": target, "autocontinue": autocontinue, "force": true }, queued, pushFront); }; /** * Adds flee order to the queue, not forced, so it can be * interrupted by attacks. */ UnitAI.prototype.Flee = function(target, queued, pushFront) { this.AddOrder("Flee", { "target": target, "force": false }, queued, pushFront); }; UnitAI.prototype.Cheer = function() { this.PushOrderFront("Cheer", { "force": false }); }; UnitAI.prototype.Pack = function(queued, pushFront) { if (this.CanPack()) this.AddOrder("Pack", { "force": true }, queued, pushFront); }; UnitAI.prototype.Unpack = function(queued, pushFront) { if (this.CanUnpack()) this.AddOrder("Unpack", { "force": true }, queued, pushFront); }; UnitAI.prototype.CancelPack = function(queued, pushFront) { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); if (cmpPack && cmpPack.IsPacking() && !cmpPack.IsPacked()) this.AddOrder("CancelPack", { "force": true }, queued, pushFront); }; UnitAI.prototype.CancelUnpack = function(queued, pushFront) { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); if (cmpPack && cmpPack.IsPacking() && cmpPack.IsPacked()) this.AddOrder("CancelUnpack", { "force": true }, queued, pushFront); }; UnitAI.prototype.SetStance = function(stance) { if (g_Stances[stance]) { this.stance = stance; Engine.PostMessage(this.entity, MT_UnitStanceChanged, { "to": this.stance }); } else error("UnitAI: Setting to invalid stance '"+stance+"'"); }; UnitAI.prototype.SwitchToStance = function(stance) { var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return; var pos = cmpPosition.GetPosition(); this.SetHeldPosition(pos.x, pos.z); this.SetStance(stance); // Reset the range queries, since the range depends on stance. this.SetupRangeQueries(); }; UnitAI.prototype.SetTurretStance = function() { this.SetImmobile(); this.previousStance = undefined; if (this.GetStance().respondStandGround) return; for (let stance in g_Stances) { if (!g_Stances[stance].respondStandGround) continue; this.previousStance = this.GetStanceName(); this.SwitchToStance(stance); return; } }; UnitAI.prototype.ResetTurretStance = function() { this.SetMobile(); if (!this.previousStance) return; this.SwitchToStance(this.previousStance); this.previousStance = undefined; }; /** * Resets the losRangeQuery. * @return {boolean} - Whether there are targets in range that we ought to react upon. */ UnitAI.prototype.FindSightedEnemies = function() { if (!this.losRangeQuery) return false; let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); return this.RespondToSightedEntities(cmpRangeManager.ResetActiveQuery(this.losRangeQuery)); }; /** * Resets losHealRangeQuery, and if there are some targets in range that we can heal * then we start healing and this returns true; otherwise, returns false. */ UnitAI.prototype.FindNewHealTargets = function() { if (!this.losHealRangeQuery) return false; let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); return this.RespondToHealableEntities(cmpRangeManager.ResetActiveQuery(this.losHealRangeQuery)); }; /** * Resets losAttackRangeQuery, and if there are some targets in range that we can * attack then we start attacking and this returns true; otherwise, returns false. */ UnitAI.prototype.FindNewTargets = function() { if (!this.losAttackRangeQuery) return false; if (!this.GetStance().targetVisibleEnemies) return false; let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); return this.AttackEntitiesByPreference(cmpRangeManager.ResetActiveQuery(this.losAttackRangeQuery)); }; UnitAI.prototype.FindWalkAndFightTargets = function() { if (this.IsFormationController()) { let foundSomething = false; let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); for (const ent of cmpFormation.members) if (Engine.QueryInterface(ent, IID_UnitAI)?.FindWalkAndFightTargets()) foundSomething = true; return foundSomething; } let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); let entities; if (!this.losAttackRangeQuery || !this.GetStance().targetVisibleEnemies || !cmpAttack) entities = []; else { let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); entities = cmpRangeManager.ResetActiveQuery(this.losAttackRangeQuery); } let attackfilter = e => { if (this?.order?.data?.targetClasses) { let cmpIdentity = Engine.QueryInterface(e, IID_Identity); let targetClasses = this.order.data.targetClasses; if (cmpIdentity && targetClasses.attack && !MatchesClassList(cmpIdentity.GetClassesList(), targetClasses.attack)) return false; if (cmpIdentity && targetClasses.avoid && MatchesClassList(cmpIdentity.GetClassesList(), targetClasses.avoid)) return false; // Only used by the AIs to prevent some choices of targets if (targetClasses.vetoEntities && targetClasses.vetoEntities[e]) return false; } let cmpOwnership = Engine.QueryInterface(e, IID_Ownership); if (cmpOwnership && cmpOwnership.GetOwner() > 0) return true; let cmpUnitAI = Engine.QueryInterface(e, IID_UnitAI); return cmpUnitAI && (!cmpUnitAI.IsAnimal() || cmpUnitAI.IsDangerousAnimal()); }; let prefs = {}; let bestPref; let targets = []; let pref; for (let v of entities) { if (this.CanAttack(v) && attackfilter(v)) { pref = cmpAttack.GetPreference(v); if (pref === 0) { this.PushOrderFront("Attack", { "target": v, "force": false, "allowCapture": this?.order?.data?.allowCapture }); return true; } targets.push(v); } prefs[v] = pref; if (pref !== undefined && (bestPref === undefined || pref < bestPref)) bestPref = pref; } for (let targ of targets) { if (prefs[targ] !== bestPref) continue; this.PushOrderFront("Attack", { "target": targ, "force": false, "allowCapture": this?.order?.data?.allowCapture }); return true; } // healers on a walk-and-fight order should heal injured units if (this.IsHealer()) return this.FindNewHealTargets(); return false; }; UnitAI.prototype.GetQueryRange = function(iid) { let ret = { "min": 0, "max": 0 }; let cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) return ret; let visionRange = cmpVision.GetRange(); if (iid === IID_Vision) { ret.max = visionRange; return ret; } if (this.GetStance().respondStandGround) { let range = this.GetRange(iid); if (!range) return ret; ret.min = range.min; ret.max = Math.min(range.max, visionRange); } else if (this.GetStance().respondChase) ret.max = visionRange; else if (this.GetStance().respondHoldGround) { let range = this.GetRange(iid); if (!range) return ret; ret.max = Math.min(range.max + visionRange / 2, visionRange); } // We probably have stance 'passive' and we wouldn't have a range, // but as it is the default for healers we need to set it to something sane. else if (iid === IID_Heal) ret.max = visionRange; return ret; }; UnitAI.prototype.GetStance = function() { return g_Stances[this.stance]; }; UnitAI.prototype.GetSelectableStances = function() { if (this.IsTurret()) return []; return Object.keys(g_Stances).filter(key => g_Stances[key].selectable); }; UnitAI.prototype.GetStanceName = function() { return this.stance; }; /* * Make the unit walk at its normal pace. */ UnitAI.prototype.ResetSpeedMultiplier = function() { let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion) cmpUnitMotion.SetSpeedMultiplier(1); }; UnitAI.prototype.SetSpeedMultiplier = function(speed) { let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion) cmpUnitMotion.SetSpeedMultiplier(speed); }; /** * Try to match the targets current movement speed. * * @param {number} target - The entity ID of the target to match. * @param {boolean} mayRun - Whether the entity is allowed to run to match the speed. */ UnitAI.prototype.TryMatchTargetSpeed = function(target, mayRun = true) { let cmpUnitMotionTarget = Engine.QueryInterface(target, IID_UnitMotion); if (cmpUnitMotionTarget) { let targetSpeed = cmpUnitMotionTarget.GetCurrentSpeed(); if (targetSpeed) this.SetSpeedMultiplier(Math.min(mayRun ? this.GetRunMultiplier() : 1, targetSpeed / this.GetWalkSpeed())); } }; /* * Remember the position of the target (in lastPos), if any, in case it disappears later * and we want to head to its last known position. * @param orderData - The order data to set this on. Defaults to this.order.data */ UnitAI.prototype.RememberTargetPosition = function(orderData) { if (!orderData) orderData = this.order.data; let cmpPosition = Engine.QueryInterface(orderData.target, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) orderData.lastPos = cmpPosition.GetPosition(); }; UnitAI.prototype.SetHeldPosition = function(x, z) { this.heldPosition = { "x": x, "z": z }; }; UnitAI.prototype.SetHeldPositionOnEntity = function(entity) { var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return; var pos = cmpPosition.GetPosition(); this.SetHeldPosition(pos.x, pos.z); }; UnitAI.prototype.GetHeldPosition = function() { return this.heldPosition; }; UnitAI.prototype.WalkToHeldPosition = function() { if (this.heldPosition) { this.AddOrder("Walk", { "x": this.heldPosition.x, "z": this.heldPosition.z, "force": false }, false, false); return true; } return false; }; // Helper functions /** * General getter for ranges. * * @param {number} iid * @param {number} target - [Optional] * @param {string} type - [Optional] * @return {Object | undefined} - The range in the form * { "min": number, "max": number } * Returns undefined when the entity does not have the requested component. */ UnitAI.prototype.GetRange = function(iid, type, target) { let component = Engine.QueryInterface(this.entity, iid); if (!component) return undefined; return component.GetRange(type, target); }; UnitAI.prototype.CanAttack = function(target) { // Formation controllers should always respond to commands // (then the individual units can make up their own minds) if (this.IsFormationController()) return true; let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); return cmpAttack && cmpAttack.CanAttack(target); }; UnitAI.prototype.CanGarrison = function(target) { // Formation controllers should always respond to commands // (then the individual units can make up their own minds). if (this.IsFormationController()) return true; let cmpGarrisonable = Engine.QueryInterface(this.entity, IID_Garrisonable); return cmpGarrisonable && cmpGarrisonable.CanGarrison(target); }; UnitAI.prototype.CanGather = function(target) { // Formation controllers should always respond to commands // (then the individual units can make up their own minds). if (this.IsFormationController()) return true; let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); return cmpResourceGatherer && cmpResourceGatherer.CanGather(target); }; UnitAI.prototype.CanHeal = function(target) { // Formation controllers should always respond to commands // (then the individual units can make up their own minds) if (this.IsFormationController()) return true; let cmpHeal = Engine.QueryInterface(this.entity, IID_Heal); return cmpHeal && cmpHeal.CanHeal(target); }; /** * Check if the entity can return carried resources at @param target * @param checkCarriedResource check we are carrying resources * @param cmpResourceGatherer if present, use this directly instead of re-querying. */ UnitAI.prototype.CanReturnResource = function(target, checkCarriedResource, cmpResourceGatherer = undefined) { // Formation controllers should always respond to commands // (then the individual units can make up their own minds). if (this.IsFormationController()) return true; if (!cmpResourceGatherer) cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); return cmpResourceGatherer && cmpResourceGatherer.CanReturnResource(target, checkCarriedResource); }; UnitAI.prototype.CanTrade = function(target) { // Formation controllers should always respond to commands // (then the individual units can make up their own minds). if (this.IsFormationController()) return true; let cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); return cmpTrader && cmpTrader.CanTrade(target); }; UnitAI.prototype.CanRepair = function(target) { // Formation controllers should always respond to commands // (then the individual units can make up their own minds). if (this.IsFormationController()) return true; let cmpBuilder = Engine.QueryInterface(this.entity, IID_Builder); return cmpBuilder && cmpBuilder.CanRepair(target); }; UnitAI.prototype.CanOccupyTurret = function(target) { // Formation controllers should always respond to commands // (then the individual units can make up their own minds). if (this.IsFormationController()) return true; let cmpTurretable = Engine.QueryInterface(this.entity, IID_Turretable); return cmpTurretable && cmpTurretable.CanOccupy(target); }; UnitAI.prototype.CanPack = function() { let cmpPack = Engine.QueryInterface(this.entity, IID_Pack); return cmpPack && cmpPack.CanPack(); }; UnitAI.prototype.CanUnpack = function() { let cmpPack = Engine.QueryInterface(this.entity, IID_Pack); return cmpPack && cmpPack.CanUnpack(); }; UnitAI.prototype.IsPacking = function() { let cmpPack = Engine.QueryInterface(this.entity, IID_Pack); return cmpPack && cmpPack.IsPacking(); }; // Formation specific functions UnitAI.prototype.IsAttackingAsFormation = function() { var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); return cmpAttack && cmpAttack.CanAttackAsFormation() && this.GetCurrentState() == "FORMATIONCONTROLLER.COMBAT.ATTACKING"; }; UnitAI.prototype.MoveRandomly = function(distance) { // To minimize drift all across the map, describe circles // approximated by polygons. // And to avoid getting stuck in obstacles or narrow spaces, each side // of the polygon is obtained by trying to go away from a point situated // half a meter backwards of the current position, after rotation. // We also add a fluctuation on the length of each side of the polygon (dist) // which, in addition to making the move more random, helps escaping narrow spaces // with bigger values of dist. let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (!cmpPosition || !cmpPosition.IsInWorld() || !cmpUnitMotion) return; let pos = cmpPosition.GetPosition(); let ang = cmpPosition.GetRotation().y; if (!this.roamAngle) { this.roamAngle = (randBool() ? 1 : -1) * Math.PI / 6; ang -= this.roamAngle / 2; this.startAngle = ang; } else if (Math.abs((ang - this.startAngle + Math.PI) % (2 * Math.PI) - Math.PI) < Math.abs(this.roamAngle / 2)) this.roamAngle *= randBool() ? 1 : -1; let halfDelta = randFloat(this.roamAngle / 4, this.roamAngle * 3 / 4); // First half rotation to decrease the impression of immediate rotation ang += halfDelta; cmpUnitMotion.FaceTowardsPoint(pos.x + 0.5 * Math.sin(ang), pos.z + 0.5 * Math.cos(ang)); // Then second half of the rotation ang += halfDelta; let dist = randFloat(0.5, 1.5) * distance; cmpUnitMotion.MoveToPointRange(pos.x - 0.5 * Math.sin(ang), pos.z - 0.5 * Math.cos(ang), dist, -1); }; UnitAI.prototype.SetFacePointAfterMove = function(val) { var cmpMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpMotion) cmpMotion.SetFacePointAfterMove(val); }; UnitAI.prototype.GetFacePointAfterMove = function() { let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion && cmpUnitMotion.GetFacePointAfterMove(); }; UnitAI.prototype.AttackEntitiesByPreference = function(ents) { if (!ents.length) return false; let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return false; let attackfilter = function(e) { if (!cmpAttack.CanAttack(e)) return false; let cmpOwnership = Engine.QueryInterface(e, IID_Ownership); if (cmpOwnership && cmpOwnership.GetOwner() > 0) return true; let cmpUnitAI = Engine.QueryInterface(e, IID_UnitAI); return cmpUnitAI && (!cmpUnitAI.IsAnimal() || cmpUnitAI.IsDangerousAnimal()); }; let entsByPreferences = {}; let preferences = []; let entsWithoutPref = []; for (let ent of ents) { if (!attackfilter(ent)) continue; let pref = cmpAttack.GetPreference(ent); if (pref === null || pref === undefined) entsWithoutPref.push(ent); else if (!entsByPreferences[pref]) { preferences.push(pref); entsByPreferences[pref] = [ent]; } else entsByPreferences[pref].push(ent); } if (preferences.length) { preferences.sort((a, b) => a - b); for (let pref of preferences) if (this.RespondToTargetedEntities(entsByPreferences[pref])) return true; } return this.RespondToTargetedEntities(entsWithoutPref); }; /** * Call UnitAI.funcname(args) on all formation members. * @param resetFinishedEntities - If true, call ResetFinishedEntities first. * If the controller wants to wait on its members to finish their order, * this needs to be reset before sending new orders (in case they instafail) * so it makes sense to do it here. * Only set this to false if you're sure it's safe. */ UnitAI.prototype.CallMemberFunction = function(funcname, args, resetFinishedEntities = true) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (!cmpFormation) return; if (resetFinishedEntities) cmpFormation.ResetFinishedEntities(); cmpFormation.GetMembers().forEach(ent => { let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); cmpUnitAI[funcname].apply(cmpUnitAI, args); }); }; /** * Call obj.funcname(args) on UnitAI components owned by player in given range. */ UnitAI.prototype.CallPlayerOwnedEntitiesFunctionInRange = function(funcname, args, range) { let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership) return; let owner = cmpOwnership.GetOwner(); if (owner == INVALID_PLAYER) return; let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); let nearby = cmpRangeManager.ExecuteQuery(this.entity, 0, range, [owner], IID_UnitAI, true); for (let i = 0; i < nearby.length; ++i) { let cmpUnitAI = Engine.QueryInterface(nearby[i], IID_UnitAI); cmpUnitAI[funcname].apply(cmpUnitAI, args); } }; /** * Call obj.functname(args) on UnitAI components of all formation members, * and return true if all calls return true. */ UnitAI.prototype.TestAllMemberFunction = function(funcname, args) { let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); return cmpFormation && cmpFormation.GetMembers().every(ent => { let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); return cmpUnitAI[funcname].apply(cmpUnitAI, args); }); }; UnitAI.prototype.UnitFsm = new FSM(UnitAI.prototype.UnitFsmSpec); Engine.RegisterComponentType(IID_UnitAI, "UnitAI", UnitAI); Index: ps/trunk/source/simulation2/components/CCmpObstructionManager.cpp =================================================================== --- ps/trunk/source/simulation2/components/CCmpObstructionManager.cpp (revision 26391) +++ ps/trunk/source/simulation2/components/CCmpObstructionManager.cpp (revision 26392) @@ -1,1346 +1,1352 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * 0 A.D. is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with 0 A.D. If not, see . */ #include "precompiled.h" #include "simulation2/system/Component.h" #include "ICmpObstructionManager.h" #include "ICmpPosition.h" +#include "ICmpRangeManager.h" #include "simulation2/MessageTypes.h" #include "simulation2/helpers/Geometry.h" #include "simulation2/helpers/Grid.h" #include "simulation2/helpers/Rasterize.h" #include "simulation2/helpers/Render.h" #include "simulation2/helpers/Spatial.h" #include "simulation2/serialization/SerializedTypes.h" #include "graphics/Overlay.h" #include "maths/MathUtil.h" #include "ps/Profile.h" #include "renderer/Scene.h" #include "ps/CLogger.h" // Externally, tags are opaque non-zero positive integers. // Internally, they are tagged (by shape) indexes into shape lists. // idx must be non-zero. #define TAG_IS_VALID(tag) ((tag).valid()) #define TAG_IS_UNIT(tag) (((tag).n & 1) == 0) #define TAG_IS_STATIC(tag) (((tag).n & 1) == 1) #define UNIT_INDEX_TO_TAG(idx) tag_t(((idx) << 1) | 0) #define STATIC_INDEX_TO_TAG(idx) tag_t(((idx) << 1) | 1) #define TAG_TO_INDEX(tag) ((tag).n >> 1) namespace { /** * Size of each obstruction subdivision square. * TODO: find the optimal number instead of blindly guessing. */ constexpr entity_pos_t OBSTRUCTION_SUBDIVISION_SIZE = entity_pos_t::FromInt(32); /** * Internal representation of axis-aligned circular shapes for moving units */ struct UnitShape { entity_id_t entity; entity_pos_t x, z; entity_pos_t clearance; ICmpObstructionManager::flags_t flags; entity_id_t group; // control group (typically the owner entity, or a formation controller entity) (units ignore collisions with others in the same group) }; /** * Internal representation of arbitrary-rotation static square shapes for buildings */ struct StaticShape { entity_id_t entity; entity_pos_t x, z; // world-space coordinates CFixedVector2D u, v; // orthogonal unit vectors - axes of local coordinate space entity_pos_t hw, hh; // half width/height in local coordinate space ICmpObstructionManager::flags_t flags; entity_id_t group; entity_id_t group2; }; } // anonymous namespace /** * Serialization helper template for UnitShape */ template<> struct SerializeHelper { template void operator()(S& serialize, const char* UNUSED(name), Serialize::qualify value) const { serialize.NumberU32_Unbounded("entity", value.entity); serialize.NumberFixed_Unbounded("x", value.x); serialize.NumberFixed_Unbounded("z", value.z); serialize.NumberFixed_Unbounded("clearance", value.clearance); serialize.NumberU8_Unbounded("flags", value.flags); serialize.NumberU32_Unbounded("group", value.group); } }; /** * Serialization helper template for StaticShape */ template<> struct SerializeHelper { template void operator()(S& serialize, const char* UNUSED(name), Serialize::qualify value) const { serialize.NumberU32_Unbounded("entity", value.entity); serialize.NumberFixed_Unbounded("x", value.x); serialize.NumberFixed_Unbounded("z", value.z); serialize.NumberFixed_Unbounded("u.x", value.u.X); serialize.NumberFixed_Unbounded("u.y", value.u.Y); serialize.NumberFixed_Unbounded("v.x", value.v.X); serialize.NumberFixed_Unbounded("v.y", value.v.Y); serialize.NumberFixed_Unbounded("hw", value.hw); serialize.NumberFixed_Unbounded("hh", value.hh); serialize.NumberU8_Unbounded("flags", value.flags); serialize.NumberU32_Unbounded("group", value.group); serialize.NumberU32_Unbounded("group2", value.group2); } }; class CCmpObstructionManager : public ICmpObstructionManager { public: static void ClassInit(CComponentManager& componentManager) { componentManager.SubscribeToMessageType(MT_RenderSubmit); // for debug overlays } DEFAULT_COMPONENT_ALLOCATOR(ObstructionManager) bool m_DebugOverlayEnabled; bool m_DebugOverlayDirty; std::vector m_DebugOverlayLines; SpatialSubdivision m_UnitSubdivision; SpatialSubdivision m_StaticSubdivision; // TODO: using std::map is a bit inefficient; is there a better way to store these? std::map m_UnitShapes; std::map m_StaticShapes; u32 m_UnitShapeNext; // next allocated id u32 m_StaticShapeNext; entity_pos_t m_MaxClearance; bool m_PassabilityCircular; entity_pos_t m_WorldX0; entity_pos_t m_WorldZ0; entity_pos_t m_WorldX1; entity_pos_t m_WorldZ1; static std::string GetSchema() { return ""; } virtual void Init(const CParamNode& UNUSED(paramNode)) { m_DebugOverlayEnabled = false; m_DebugOverlayDirty = true; m_UnitShapeNext = 1; m_StaticShapeNext = 1; m_UpdateInformations.dirty = true; m_UpdateInformations.globallyDirty = true; m_PassabilityCircular = false; m_WorldX0 = m_WorldZ0 = m_WorldX1 = m_WorldZ1 = entity_pos_t::Zero(); // Initialise with bogus values (these will get replaced when // SetBounds is called) ResetSubdivisions(entity_pos_t::FromInt(1024), entity_pos_t::FromInt(1024)); } virtual void Deinit() { } template void SerializeCommon(S& serialize) { Serializer(serialize, "unit subdiv", m_UnitSubdivision); Serializer(serialize, "static subdiv", m_StaticSubdivision); serialize.NumberFixed_Unbounded("max clearance", m_MaxClearance); Serializer(serialize, "unit shapes", m_UnitShapes); Serializer(serialize, "static shapes", m_StaticShapes); serialize.NumberU32_Unbounded("unit shape next", m_UnitShapeNext); serialize.NumberU32_Unbounded("static shape next", m_StaticShapeNext); serialize.Bool("circular", m_PassabilityCircular); serialize.NumberFixed_Unbounded("world x0", m_WorldX0); serialize.NumberFixed_Unbounded("world z0", m_WorldZ0); serialize.NumberFixed_Unbounded("world x1", m_WorldX1); serialize.NumberFixed_Unbounded("world z1", m_WorldZ1); } virtual void Serialize(ISerializer& serialize) { // TODO: this could perhaps be optimised by not storing all the obstructions, // and instead regenerating them from the other entities on Deserialize SerializeCommon(serialize); } virtual void Deserialize(const CParamNode& paramNode, IDeserializer& deserialize) { Init(paramNode); SerializeCommon(deserialize); i32 size = ((m_WorldX1-m_WorldX0)/Pathfinding::NAVCELL_SIZE_INT).ToInt_RoundToInfinity(); m_UpdateInformations.dirtinessGrid = Grid(size, size); } virtual void HandleMessage(const CMessage& msg, bool UNUSED(global)) { switch (msg.GetType()) { case MT_RenderSubmit: { const CMessageRenderSubmit& msgData = static_cast (msg); RenderSubmit(msgData.collector); break; } } } // NB: on deserialization, this function is not called after the component is reset. // So anything that happens here should be safely serialized. virtual void SetBounds(entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1) { m_WorldX0 = x0; m_WorldZ0 = z0; m_WorldX1 = x1; m_WorldZ1 = z1; MakeDirtyAll(); // Subdivision system bounds: ENSURE(x0.IsZero() && z0.IsZero()); // don't bother implementing non-zero offsets yet ResetSubdivisions(x1, z1); i32 size = ((m_WorldX1-m_WorldX0)/Pathfinding::NAVCELL_SIZE_INT).ToInt_RoundToInfinity(); m_UpdateInformations.dirtinessGrid = Grid(size, size); CmpPtr cmpPathfinder(GetSystemEntity()); if (cmpPathfinder) m_MaxClearance = cmpPathfinder->GetMaximumClearance(); } void ResetSubdivisions(entity_pos_t x1, entity_pos_t z1) { m_UnitSubdivision.Reset(x1, z1, OBSTRUCTION_SUBDIVISION_SIZE); m_StaticSubdivision.Reset(x1, z1, OBSTRUCTION_SUBDIVISION_SIZE); for (std::map::iterator it = m_UnitShapes.begin(); it != m_UnitShapes.end(); ++it) { CFixedVector2D center(it->second.x, it->second.z); CFixedVector2D halfSize(it->second.clearance, it->second.clearance); m_UnitSubdivision.Add(it->first, center - halfSize, center + halfSize); } for (std::map::iterator it = m_StaticShapes.begin(); it != m_StaticShapes.end(); ++it) { CFixedVector2D center(it->second.x, it->second.z); CFixedVector2D bbHalfSize = Geometry::GetHalfBoundingBox(it->second.u, it->second.v, CFixedVector2D(it->second.hw, it->second.hh)); m_StaticSubdivision.Add(it->first, center - bbHalfSize, center + bbHalfSize); } } virtual tag_t AddUnitShape(entity_id_t ent, entity_pos_t x, entity_pos_t z, entity_pos_t clearance, flags_t flags, entity_id_t group) { UnitShape shape = { ent, x, z, clearance, flags, group }; u32 id = m_UnitShapeNext++; m_UnitShapes[id] = shape; m_UnitSubdivision.Add(id, CFixedVector2D(x - clearance, z - clearance), CFixedVector2D(x + clearance, z + clearance)); MakeDirtyUnit(flags, id, shape); return UNIT_INDEX_TO_TAG(id); } virtual tag_t AddStaticShape(entity_id_t ent, entity_pos_t x, entity_pos_t z, entity_angle_t a, entity_pos_t w, entity_pos_t h, flags_t flags, entity_id_t group, entity_id_t group2 /* = INVALID_ENTITY */) { fixed s, c; sincos_approx(a, s, c); CFixedVector2D u(c, -s); CFixedVector2D v(s, c); StaticShape shape = { ent, x, z, u, v, w/2, h/2, flags, group, group2 }; u32 id = m_StaticShapeNext++; m_StaticShapes[id] = shape; CFixedVector2D center(x, z); CFixedVector2D bbHalfSize = Geometry::GetHalfBoundingBox(u, v, CFixedVector2D(w/2, h/2)); m_StaticSubdivision.Add(id, center - bbHalfSize, center + bbHalfSize); MakeDirtyStatic(flags, id, shape); return STATIC_INDEX_TO_TAG(id); } virtual ObstructionSquare GetUnitShapeObstruction(entity_pos_t x, entity_pos_t z, entity_pos_t clearance) const { CFixedVector2D u(entity_pos_t::FromInt(1), entity_pos_t::Zero()); CFixedVector2D v(entity_pos_t::Zero(), entity_pos_t::FromInt(1)); ObstructionSquare o = { x, z, u, v, clearance, clearance }; return o; } virtual ObstructionSquare GetStaticShapeObstruction(entity_pos_t x, entity_pos_t z, entity_angle_t a, entity_pos_t w, entity_pos_t h) const { fixed s, c; sincos_approx(a, s, c); CFixedVector2D u(c, -s); CFixedVector2D v(s, c); ObstructionSquare o = { x, z, u, v, w/2, h/2 }; return o; } virtual void MoveShape(tag_t tag, entity_pos_t x, entity_pos_t z, entity_angle_t a) { ENSURE(TAG_IS_VALID(tag)); if (TAG_IS_UNIT(tag)) { UnitShape& shape = m_UnitShapes[TAG_TO_INDEX(tag)]; MakeDirtyUnit(shape.flags, TAG_TO_INDEX(tag), shape); // dirty the old shape region m_UnitSubdivision.Move(TAG_TO_INDEX(tag), CFixedVector2D(shape.x - shape.clearance, shape.z - shape.clearance), CFixedVector2D(shape.x + shape.clearance, shape.z + shape.clearance), CFixedVector2D(x - shape.clearance, z - shape.clearance), CFixedVector2D(x + shape.clearance, z + shape.clearance)); shape.x = x; shape.z = z; MakeDirtyUnit(shape.flags, TAG_TO_INDEX(tag), shape); // dirty the new shape region } else { fixed s, c; sincos_approx(a, s, c); CFixedVector2D u(c, -s); CFixedVector2D v(s, c); StaticShape& shape = m_StaticShapes[TAG_TO_INDEX(tag)]; MakeDirtyStatic(shape.flags, TAG_TO_INDEX(tag), shape); // dirty the old shape region CFixedVector2D fromBbHalfSize = Geometry::GetHalfBoundingBox(shape.u, shape.v, CFixedVector2D(shape.hw, shape.hh)); CFixedVector2D toBbHalfSize = Geometry::GetHalfBoundingBox(u, v, CFixedVector2D(shape.hw, shape.hh)); m_StaticSubdivision.Move(TAG_TO_INDEX(tag), CFixedVector2D(shape.x, shape.z) - fromBbHalfSize, CFixedVector2D(shape.x, shape.z) + fromBbHalfSize, CFixedVector2D(x, z) - toBbHalfSize, CFixedVector2D(x, z) + toBbHalfSize); shape.x = x; shape.z = z; shape.u = u; shape.v = v; MakeDirtyStatic(shape.flags, TAG_TO_INDEX(tag), shape); // dirty the new shape region } } virtual void SetUnitMovingFlag(tag_t tag, bool moving) { ENSURE(TAG_IS_VALID(tag) && TAG_IS_UNIT(tag)); if (TAG_IS_UNIT(tag)) { UnitShape& shape = m_UnitShapes[TAG_TO_INDEX(tag)]; if (moving) shape.flags |= FLAG_MOVING; else shape.flags &= (flags_t)~FLAG_MOVING; MakeDirtyDebug(); } } virtual void SetUnitControlGroup(tag_t tag, entity_id_t group) { ENSURE(TAG_IS_VALID(tag) && TAG_IS_UNIT(tag)); if (TAG_IS_UNIT(tag)) { UnitShape& shape = m_UnitShapes[TAG_TO_INDEX(tag)]; shape.group = group; } } virtual void SetStaticControlGroup(tag_t tag, entity_id_t group, entity_id_t group2) { ENSURE(TAG_IS_VALID(tag) && TAG_IS_STATIC(tag)); if (TAG_IS_STATIC(tag)) { StaticShape& shape = m_StaticShapes[TAG_TO_INDEX(tag)]; shape.group = group; shape.group2 = group2; } } virtual void RemoveShape(tag_t tag) { ENSURE(TAG_IS_VALID(tag)); if (TAG_IS_UNIT(tag)) { UnitShape& shape = m_UnitShapes[TAG_TO_INDEX(tag)]; m_UnitSubdivision.Remove(TAG_TO_INDEX(tag), CFixedVector2D(shape.x - shape.clearance, shape.z - shape.clearance), CFixedVector2D(shape.x + shape.clearance, shape.z + shape.clearance)); MakeDirtyUnit(shape.flags, TAG_TO_INDEX(tag), shape); m_UnitShapes.erase(TAG_TO_INDEX(tag)); } else { StaticShape& shape = m_StaticShapes[TAG_TO_INDEX(tag)]; CFixedVector2D center(shape.x, shape.z); CFixedVector2D bbHalfSize = Geometry::GetHalfBoundingBox(shape.u, shape.v, CFixedVector2D(shape.hw, shape.hh)); m_StaticSubdivision.Remove(TAG_TO_INDEX(tag), center - bbHalfSize, center + bbHalfSize); MakeDirtyStatic(shape.flags, TAG_TO_INDEX(tag), shape); m_StaticShapes.erase(TAG_TO_INDEX(tag)); } } virtual ObstructionSquare GetObstruction(tag_t tag) const { ENSURE(TAG_IS_VALID(tag)); if (TAG_IS_UNIT(tag)) { const UnitShape& shape = m_UnitShapes.at(TAG_TO_INDEX(tag)); CFixedVector2D u(entity_pos_t::FromInt(1), entity_pos_t::Zero()); CFixedVector2D v(entity_pos_t::Zero(), entity_pos_t::FromInt(1)); ObstructionSquare o = { shape.x, shape.z, u, v, shape.clearance, shape.clearance }; return o; } else { const StaticShape& shape = m_StaticShapes.at(TAG_TO_INDEX(tag)); ObstructionSquare o = { shape.x, shape.z, shape.u, shape.v, shape.hw, shape.hh }; return o; } } virtual fixed DistanceToPoint(entity_id_t ent, entity_pos_t px, entity_pos_t pz) const; virtual fixed MaxDistanceToPoint(entity_id_t ent, entity_pos_t px, entity_pos_t pz) const; virtual fixed DistanceToTarget(entity_id_t ent, entity_id_t target) const; virtual fixed MaxDistanceToTarget(entity_id_t ent, entity_id_t target) const; virtual fixed DistanceBetweenShapes(const ObstructionSquare& source, const ObstructionSquare& target) const; virtual fixed MaxDistanceBetweenShapes(const ObstructionSquare& source, const ObstructionSquare& target) const; virtual bool IsInPointRange(entity_id_t ent, entity_pos_t px, entity_pos_t pz, entity_pos_t minRange, entity_pos_t maxRange, bool opposite) const; virtual bool IsInTargetRange(entity_id_t ent, entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange, bool opposite) const; + virtual bool IsInTargetParabolicRange(entity_id_t ent, entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t yOrigin, bool opposite) const; virtual bool IsPointInPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t px, entity_pos_t pz, entity_pos_t minRange, entity_pos_t maxRange) const; virtual bool AreShapesInRange(const ObstructionSquare& source, const ObstructionSquare& target, entity_pos_t minRange, entity_pos_t maxRange, bool opposite) const; virtual bool TestLine(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, entity_pos_t r, bool relaxClearanceForUnits = false) const; virtual bool TestStaticShape(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t a, entity_pos_t w, entity_pos_t h, std::vector* out) const; virtual bool TestUnitShape(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t r, std::vector* out) const; virtual void Rasterize(Grid& grid, const std::vector& passClasses, bool fullUpdate); virtual void GetObstructionsInRange(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, std::vector& squares) const; virtual void GetUnitObstructionsInRange(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, std::vector& squares) const; virtual void GetStaticObstructionsInRange(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, std::vector& squares) const; virtual void GetUnitsOnObstruction(const ObstructionSquare& square, std::vector& out, const IObstructionTestFilter& filter, bool strict = false) const; virtual void GetStaticObstructionsOnObstruction(const ObstructionSquare& square, std::vector& out, const IObstructionTestFilter& filter) const; virtual void SetPassabilityCircular(bool enabled) { m_PassabilityCircular = enabled; MakeDirtyAll(); CMessageObstructionMapShapeChanged msg; GetSimContext().GetComponentManager().BroadcastMessage(msg); } virtual bool GetPassabilityCircular() const { return m_PassabilityCircular; } virtual void SetDebugOverlay(bool enabled) { m_DebugOverlayEnabled = enabled; m_DebugOverlayDirty = true; if (!enabled) m_DebugOverlayLines.clear(); } void RenderSubmit(SceneCollector& collector); virtual void UpdateInformations(GridUpdateInformation& informations) { if (!m_UpdateInformations.dirtinessGrid.blank()) informations.MergeAndClear(m_UpdateInformations); } private: // Dynamic updates for the long-range pathfinder GridUpdateInformation m_UpdateInformations; // These vectors might contain shapes that were deleted std::vector m_DirtyStaticShapes; std::vector m_DirtyUnitShapes; /** * Mark all previous Rasterize()d grids as dirty, and the debug display. * Call this when the world bounds have changed. */ void MakeDirtyAll() { m_UpdateInformations.dirty = true; m_UpdateInformations.globallyDirty = true; m_UpdateInformations.dirtinessGrid.reset(); m_DebugOverlayDirty = true; } /** * Mark the debug display as dirty. * Call this when nothing has changed except a unit's 'moving' flag. */ void MakeDirtyDebug() { m_DebugOverlayDirty = true; } inline void MarkDirtinessGrid(const entity_pos_t& x, const entity_pos_t& z, const entity_pos_t& r) { MarkDirtinessGrid(x, z, CFixedVector2D(r, r)); } inline void MarkDirtinessGrid(const entity_pos_t& x, const entity_pos_t& z, const CFixedVector2D& hbox) { if (m_UpdateInformations.dirtinessGrid.m_W == 0) return; u16 j0, j1, i0, i1; Pathfinding::NearestNavcell(x - hbox.X, z - hbox.Y, i0, j0, m_UpdateInformations.dirtinessGrid.m_W, m_UpdateInformations.dirtinessGrid.m_H); Pathfinding::NearestNavcell(x + hbox.X, z + hbox.Y, i1, j1, m_UpdateInformations.dirtinessGrid.m_W, m_UpdateInformations.dirtinessGrid.m_H); for (int j = j0; j < j1; ++j) for (int i = i0; i < i1; ++i) m_UpdateInformations.dirtinessGrid.set(i, j, 1); } /** * Mark all previous Rasterize()d grids as dirty, if they depend on this shape. * Call this when a static shape has changed. */ void MakeDirtyStatic(flags_t flags, u32 index, const StaticShape& shape) { m_DebugOverlayDirty = true; if (flags & (FLAG_BLOCK_PATHFINDING | FLAG_BLOCK_FOUNDATION)) { m_UpdateInformations.dirty = true; if (std::find(m_DirtyStaticShapes.begin(), m_DirtyStaticShapes.end(), index) == m_DirtyStaticShapes.end()) m_DirtyStaticShapes.push_back(index); // All shapes overlapping the updated part of the grid should be dirtied too. // We are going to invalidate the region of the grid corresponding to the modified shape plus its clearance, // and we need to get the shapes whose clearance can overlap this area. So we need to extend the search area // by two times the maximum clearance. CFixedVector2D center(shape.x, shape.z); CFixedVector2D hbox = Geometry::GetHalfBoundingBox(shape.u, shape.v, CFixedVector2D(shape.hw, shape.hh)); CFixedVector2D expand(m_MaxClearance, m_MaxClearance); std::vector staticsNear; m_StaticSubdivision.GetInRange(staticsNear, center - hbox - expand*2, center + hbox + expand*2); for (u32& staticId : staticsNear) if (std::find(m_DirtyStaticShapes.begin(), m_DirtyStaticShapes.end(), staticId) == m_DirtyStaticShapes.end()) m_DirtyStaticShapes.push_back(staticId); std::vector unitsNear; m_UnitSubdivision.GetInRange(unitsNear, center - hbox - expand*2, center + hbox + expand*2); for (u32& unitId : unitsNear) if (std::find(m_DirtyUnitShapes.begin(), m_DirtyUnitShapes.end(), unitId) == m_DirtyUnitShapes.end()) m_DirtyUnitShapes.push_back(unitId); MarkDirtinessGrid(shape.x, shape.z, hbox + expand); } } /** * Mark all previous Rasterize()d grids as dirty, if they depend on this shape. * Call this when a unit shape has changed. */ void MakeDirtyUnit(flags_t flags, u32 index, const UnitShape& shape) { m_DebugOverlayDirty = true; if (flags & (FLAG_BLOCK_PATHFINDING | FLAG_BLOCK_FOUNDATION)) { m_UpdateInformations.dirty = true; if (std::find(m_DirtyUnitShapes.begin(), m_DirtyUnitShapes.end(), index) == m_DirtyUnitShapes.end()) m_DirtyUnitShapes.push_back(index); // All shapes overlapping the updated part of the grid should be dirtied too. // We are going to invalidate the region of the grid corresponding to the modified shape plus its clearance, // and we need to get the shapes whose clearance can overlap this area. So we need to extend the search area // by two times the maximum clearance. CFixedVector2D center(shape.x, shape.z); std::vector staticsNear; m_StaticSubdivision.GetNear(staticsNear, center, shape.clearance + m_MaxClearance*2); for (u32& staticId : staticsNear) if (std::find(m_DirtyStaticShapes.begin(), m_DirtyStaticShapes.end(), staticId) == m_DirtyStaticShapes.end()) m_DirtyStaticShapes.push_back(staticId); std::vector unitsNear; m_UnitSubdivision.GetNear(unitsNear, center, shape.clearance + m_MaxClearance*2); for (u32& unitId : unitsNear) if (std::find(m_DirtyUnitShapes.begin(), m_DirtyUnitShapes.end(), unitId) == m_DirtyUnitShapes.end()) m_DirtyUnitShapes.push_back(unitId); MarkDirtinessGrid(shape.x, shape.z, shape.clearance + m_MaxClearance); } } /** * Return whether the given point is within the world bounds by at least r */ inline bool IsInWorld(entity_pos_t x, entity_pos_t z, entity_pos_t r) const { return (m_WorldX0+r <= x && x <= m_WorldX1-r && m_WorldZ0+r <= z && z <= m_WorldZ1-r); } /** * Return whether the given point is within the world bounds */ inline bool IsInWorld(const CFixedVector2D& p) const { return (m_WorldX0 <= p.X && p.X <= m_WorldX1 && m_WorldZ0 <= p.Y && p.Y <= m_WorldZ1); } void RasterizeHelper(Grid& grid, ICmpObstructionManager::flags_t requireMask, bool fullUpdate, pass_class_t appliedMask, entity_pos_t clearance = fixed::Zero()) const; }; REGISTER_COMPONENT_TYPE(ObstructionManager) /** * DistanceTo function family, all end up in calculating a vector length, DistanceBetweenShapes or * MaxDistanceBetweenShapes. The MaxFoo family calculates the opposite edge opposite edge distance. * When the distance is undefined we return -1. */ fixed CCmpObstructionManager::DistanceToPoint(entity_id_t ent, entity_pos_t px, entity_pos_t pz) const { ObstructionSquare obstruction; CmpPtr cmpObstruction(GetSimContext(), ent); if (cmpObstruction && cmpObstruction->GetObstructionSquare(obstruction)) { ObstructionSquare point; point.x = px; point.z = pz; return DistanceBetweenShapes(obstruction, point); } CmpPtr cmpPosition(GetSimContext(), ent); if (!cmpPosition || !cmpPosition->IsInWorld()) return fixed::FromInt(-1); return (CFixedVector2D(cmpPosition->GetPosition2D().X, cmpPosition->GetPosition2D().Y) - CFixedVector2D(px, pz)).Length(); } fixed CCmpObstructionManager::MaxDistanceToPoint(entity_id_t ent, entity_pos_t px, entity_pos_t pz) const { ObstructionSquare obstruction; CmpPtr cmpObstruction(GetSimContext(), ent); if (!cmpObstruction || !cmpObstruction->GetObstructionSquare(obstruction)) { ObstructionSquare point; point.x = px; point.z = pz; return MaxDistanceBetweenShapes(obstruction, point); } CmpPtr cmpPosition(GetSimContext(), ent); if (!cmpPosition || !cmpPosition->IsInWorld()) return fixed::FromInt(-1); return (CFixedVector2D(cmpPosition->GetPosition2D().X, cmpPosition->GetPosition2D().Y) - CFixedVector2D(px, pz)).Length(); } fixed CCmpObstructionManager::DistanceToTarget(entity_id_t ent, entity_id_t target) const { ObstructionSquare obstruction; CmpPtr cmpObstruction(GetSimContext(), ent); if (!cmpObstruction || !cmpObstruction->GetObstructionSquare(obstruction)) { CmpPtr cmpPosition(GetSimContext(), ent); if (!cmpPosition || !cmpPosition->IsInWorld()) return fixed::FromInt(-1); return DistanceToPoint(target, cmpPosition->GetPosition2D().X, cmpPosition->GetPosition2D().Y); } ObstructionSquare target_obstruction; CmpPtr cmpObstructionTarget(GetSimContext(), target); if (!cmpObstructionTarget || !cmpObstructionTarget->GetObstructionSquare(target_obstruction)) { CmpPtr cmpPositionTarget(GetSimContext(), target); if (!cmpPositionTarget || !cmpPositionTarget->IsInWorld()) return fixed::FromInt(-1); return DistanceToPoint(ent, cmpPositionTarget->GetPosition2D().X, cmpPositionTarget->GetPosition2D().Y); } return DistanceBetweenShapes(obstruction, target_obstruction); } fixed CCmpObstructionManager::MaxDistanceToTarget(entity_id_t ent, entity_id_t target) const { ObstructionSquare obstruction; CmpPtr cmpObstruction(GetSimContext(), ent); if (!cmpObstruction || !cmpObstruction->GetObstructionSquare(obstruction)) { CmpPtr cmpPosition(GetSimContext(), ent); if (!cmpPosition || !cmpPosition->IsInWorld()) return fixed::FromInt(-1); return MaxDistanceToPoint(target, cmpPosition->GetPosition2D().X, cmpPosition->GetPosition2D().Y); } ObstructionSquare target_obstruction; CmpPtr cmpObstructionTarget(GetSimContext(), target); if (!cmpObstructionTarget || !cmpObstructionTarget->GetObstructionSquare(target_obstruction)) { CmpPtr cmpPositionTarget(GetSimContext(), target); if (!cmpPositionTarget || !cmpPositionTarget->IsInWorld()) return fixed::FromInt(-1); return MaxDistanceToPoint(ent, cmpPositionTarget->GetPosition2D().X, cmpPositionTarget->GetPosition2D().Y); } return MaxDistanceBetweenShapes(obstruction, target_obstruction); } fixed CCmpObstructionManager::DistanceBetweenShapes(const ObstructionSquare& source, const ObstructionSquare& target) const { // Sphere-sphere collision. if (source.hh == fixed::Zero() && target.hh == fixed::Zero()) return (CFixedVector2D(target.x, target.z) - CFixedVector2D(source.x, source.z)).Length() - source.hw - target.hw; // Square to square. if (source.hh != fixed::Zero() && target.hh != fixed::Zero()) return Geometry::DistanceSquareToSquare( CFixedVector2D(target.x, target.z) - CFixedVector2D(source.x, source.z), source.u, source.v, CFixedVector2D(source.hw, source.hh), target.u, target.v, CFixedVector2D(target.hw, target.hh)); // To cover both remaining cases, shape a is the square one, shape b is the circular one. const ObstructionSquare& a = source.hh == fixed::Zero() ? target : source; const ObstructionSquare& b = source.hh == fixed::Zero() ? source : target; return Geometry::DistanceToSquare( CFixedVector2D(b.x, b.z) - CFixedVector2D(a.x, a.z), a.u, a.v, CFixedVector2D(a.hw, a.hh), true) - b.hw; } fixed CCmpObstructionManager::MaxDistanceBetweenShapes(const ObstructionSquare& source, const ObstructionSquare& target) const { // Sphere-sphere collision. if (source.hh == fixed::Zero() && target.hh == fixed::Zero()) return (CFixedVector2D(target.x, target.z) - CFixedVector2D(source.x, source.z)).Length() + source.hw + target.hw; // Square to square. if (source.hh != fixed::Zero() && target.hh != fixed::Zero()) return Geometry::MaxDistanceSquareToSquare( CFixedVector2D(target.x, target.z) - CFixedVector2D(source.x, source.z), source.u, source.v, CFixedVector2D(source.hw, source.hh), target.u, target.v, CFixedVector2D(target.hw, target.hh)); // To cover both remaining cases, shape a is the square one, shape b is the circular one. const ObstructionSquare& a = source.hh == fixed::Zero() ? target : source; const ObstructionSquare& b = source.hh == fixed::Zero() ? source : target; return Geometry::MaxDistanceToSquare( CFixedVector2D(b.x, b.z) - CFixedVector2D(a.x, a.z), a.u, a.v, CFixedVector2D(a.hw, a.hh), true) + b.hw; } /** * IsInRange function family depending on the DistanceTo family. * * In range if the edge to edge distance is inferior to maxRange * and if the opposite edge to opposite edge distance is greater than minRange when the opposite bool is true * or when the opposite bool is false the edge to edge distance is more than minRange. * * Using the opposite egde for minRange means that a unit is in range of a building if it is farther than * clearance-buildingsize, which is generally going to be negative (and thus this returns true). * NB: from a game POV, this means units can easily fire on buildings, which is good, * but it also means that buildings can easily fire on units. Buildings are usually meant * to fire from the edge, not the opposite edge, so this looks odd. For this reason one can choose * to set the opposite bool false and use the edge to egde distance. * * We don't use squares because the are likely to overflow. + * TODO Avoid the overflows and use squares instead. * We use a 0.0001 margin to avoid rounding errors. */ bool CCmpObstructionManager::IsInPointRange(entity_id_t ent, entity_pos_t px, entity_pos_t pz, entity_pos_t minRange, entity_pos_t maxRange, bool opposite) const { fixed dist = DistanceToPoint(ent, px, pz); - // Treat -1 max range as infinite - return dist != fixed::FromInt(-1) && - (dist <= (maxRange + fixed::FromFloat(0.0001f)) || maxRange < fixed::Zero()) && + return maxRange != NEVER_IN_RANGE && dist != fixed::FromInt(-1) && + (dist <= (maxRange + fixed::FromFloat(0.0001f)) || maxRange == ALWAYS_IN_RANGE) && (opposite ? MaxDistanceToPoint(ent, px, pz) : dist) >= minRange - fixed::FromFloat(0.0001f); } bool CCmpObstructionManager::IsInTargetRange(entity_id_t ent, entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange, bool opposite) const { fixed dist = DistanceToTarget(ent, target); - // Treat -1 max range as infinite - return dist != fixed::FromInt(-1) && - (dist <= (maxRange + fixed::FromFloat(0.0001f)) || maxRange < fixed::Zero()) && + return maxRange != NEVER_IN_RANGE && dist != fixed::FromInt(-1) && + (dist <= (maxRange + fixed::FromFloat(0.0001f)) || maxRange == ALWAYS_IN_RANGE) && (opposite ? MaxDistanceToTarget(ent, target) : dist) >= minRange - fixed::FromFloat(0.0001f); } + +bool CCmpObstructionManager::IsInTargetParabolicRange(entity_id_t ent, entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t yOrigin, bool opposite) const +{ + CmpPtr cmpRangeManager(GetSystemEntity()); + return IsInTargetRange(ent, target, minRange, cmpRangeManager->GetEffectiveParabolicRange(ent, target, maxRange, yOrigin), opposite); +} + bool CCmpObstructionManager::IsPointInPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t px, entity_pos_t pz, entity_pos_t minRange, entity_pos_t maxRange) const { entity_pos_t distance = (CFixedVector2D(x, z) - CFixedVector2D(px, pz)).Length(); - // Treat -1 max range as infinite - return (distance <= (maxRange + fixed::FromFloat(0.0001f)) || maxRange < fixed::Zero()) && + return maxRange != NEVER_IN_RANGE && (distance <= (maxRange + fixed::FromFloat(0.0001f)) || maxRange == ALWAYS_IN_RANGE) && distance >= minRange - fixed::FromFloat(0.0001f); } bool CCmpObstructionManager::AreShapesInRange(const ObstructionSquare& source, const ObstructionSquare& target, entity_pos_t minRange, entity_pos_t maxRange, bool opposite) const { fixed dist = DistanceBetweenShapes(source, target); - // Treat -1 max range as infinite - return dist != fixed::FromInt(-1) && - (dist <= (maxRange + fixed::FromFloat(0.0001f)) || maxRange < fixed::Zero()) && + return maxRange != NEVER_IN_RANGE && dist != fixed::FromInt(-1) && + (dist <= (maxRange + fixed::FromFloat(0.0001f)) || maxRange == ALWAYS_IN_RANGE) && (opposite ? MaxDistanceBetweenShapes(source, target) : dist) >= minRange - fixed::FromFloat(0.0001f); } bool CCmpObstructionManager::TestLine(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, entity_pos_t r, bool relaxClearanceForUnits) const { PROFILE("TestLine"); // Check that both end points are within the world (which means the whole line must be) if (!IsInWorld(x0, z0, r) || !IsInWorld(x1, z1, r)) return true; CFixedVector2D posMin (std::min(x0, x1) - r, std::min(z0, z1) - r); CFixedVector2D posMax (std::max(x0, x1) + r, std::max(z0, z1) + r); // actual radius used for unit-unit collisions. If relaxClearanceForUnits, will be smaller to allow more overlap. entity_pos_t unitUnitRadius = r; if (relaxClearanceForUnits) unitUnitRadius -= entity_pos_t::FromInt(1)/2; std::vector unitShapes; m_UnitSubdivision.GetInRange(unitShapes, posMin, posMax); for (const entity_id_t& shape : unitShapes) { std::map::const_iterator it = m_UnitShapes.find(shape); ENSURE(it != m_UnitShapes.end()); if (!filter.TestShape(UNIT_INDEX_TO_TAG(it->first), it->second.flags, it->second.group, INVALID_ENTITY)) continue; CFixedVector2D center(it->second.x, it->second.z); CFixedVector2D halfSize(it->second.clearance + unitUnitRadius, it->second.clearance + unitUnitRadius); if (Geometry::TestRayAASquare(CFixedVector2D(x0, z0) - center, CFixedVector2D(x1, z1) - center, halfSize)) return true; } std::vector staticShapes; m_StaticSubdivision.GetInRange(staticShapes, posMin, posMax); for (const entity_id_t& shape : staticShapes) { std::map::const_iterator it = m_StaticShapes.find(shape); ENSURE(it != m_StaticShapes.end()); if (!filter.TestShape(STATIC_INDEX_TO_TAG(it->first), it->second.flags, it->second.group, it->second.group2)) continue; CFixedVector2D center(it->second.x, it->second.z); CFixedVector2D halfSize(it->second.hw + r, it->second.hh + r); if (Geometry::TestRaySquare(CFixedVector2D(x0, z0) - center, CFixedVector2D(x1, z1) - center, it->second.u, it->second.v, halfSize)) return true; } return false; } bool CCmpObstructionManager::TestStaticShape(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t a, entity_pos_t w, entity_pos_t h, std::vector* out) const { PROFILE("TestStaticShape"); if (out) out->clear(); fixed s, c; sincos_approx(a, s, c); CFixedVector2D u(c, -s); CFixedVector2D v(s, c); CFixedVector2D center(x, z); CFixedVector2D halfSize(w/2, h/2); CFixedVector2D corner1 = u.Multiply(halfSize.X) + v.Multiply(halfSize.Y); CFixedVector2D corner2 = u.Multiply(halfSize.X) - v.Multiply(halfSize.Y); // Check that all corners are within the world (which means the whole shape must be) if (!IsInWorld(center + corner1) || !IsInWorld(center + corner2) || !IsInWorld(center - corner1) || !IsInWorld(center - corner2)) { if (out) out->push_back(INVALID_ENTITY); // no entity ID, so just push an arbitrary marker else return true; } fixed bbHalfWidth = std::max(corner1.X.Absolute(), corner2.X.Absolute()); fixed bbHalfHeight = std::max(corner1.Y.Absolute(), corner2.Y.Absolute()); CFixedVector2D posMin(x - bbHalfWidth, z - bbHalfHeight); CFixedVector2D posMax(x + bbHalfWidth, z + bbHalfHeight); std::vector unitShapes; m_UnitSubdivision.GetInRange(unitShapes, posMin, posMax); for (entity_id_t& shape : unitShapes) { std::map::const_iterator it = m_UnitShapes.find(shape); ENSURE(it != m_UnitShapes.end()); if (!filter.TestShape(UNIT_INDEX_TO_TAG(it->first), it->second.flags, it->second.group, INVALID_ENTITY)) continue; CFixedVector2D center1(it->second.x, it->second.z); if (Geometry::PointIsInSquare(center1 - center, u, v, CFixedVector2D(halfSize.X + it->second.clearance, halfSize.Y + it->second.clearance))) { if (out) out->push_back(it->second.entity); else return true; } } std::vector staticShapes; m_StaticSubdivision.GetInRange(staticShapes, posMin, posMax); for (entity_id_t& shape : staticShapes) { std::map::const_iterator it = m_StaticShapes.find(shape); ENSURE(it != m_StaticShapes.end()); if (!filter.TestShape(STATIC_INDEX_TO_TAG(it->first), it->second.flags, it->second.group, it->second.group2)) continue; CFixedVector2D center1(it->second.x, it->second.z); CFixedVector2D halfSize1(it->second.hw, it->second.hh); if (Geometry::TestSquareSquare(center, u, v, halfSize, center1, it->second.u, it->second.v, halfSize1)) { if (out) out->push_back(it->second.entity); else return true; } } if (out) return !out->empty(); // collided if the list isn't empty else return false; // didn't collide, if we got this far } bool CCmpObstructionManager::TestUnitShape(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t clearance, std::vector* out) const { PROFILE("TestUnitShape"); // Check that the shape is within the world if (!IsInWorld(x, z, clearance)) { if (out) out->push_back(INVALID_ENTITY); // no entity ID, so just push an arbitrary marker else return true; } CFixedVector2D center(x, z); CFixedVector2D posMin(x - clearance, z - clearance); CFixedVector2D posMax(x + clearance, z + clearance); std::vector unitShapes; m_UnitSubdivision.GetInRange(unitShapes, posMin, posMax); for (const entity_id_t& shape : unitShapes) { std::map::const_iterator it = m_UnitShapes.find(shape); ENSURE(it != m_UnitShapes.end()); if (!filter.TestShape(UNIT_INDEX_TO_TAG(it->first), it->second.flags, it->second.group, INVALID_ENTITY)) continue; entity_pos_t c1 = it->second.clearance; if (!( it->second.x + c1 < x - clearance || it->second.x - c1 > x + clearance || it->second.z + c1 < z - clearance || it->second.z - c1 > z + clearance)) { if (out) out->push_back(it->second.entity); else return true; } } std::vector staticShapes; m_StaticSubdivision.GetInRange(staticShapes, posMin, posMax); for (const entity_id_t& shape : staticShapes) { std::map::const_iterator it = m_StaticShapes.find(shape); ENSURE(it != m_StaticShapes.end()); if (!filter.TestShape(STATIC_INDEX_TO_TAG(it->first), it->second.flags, it->second.group, it->second.group2)) continue; CFixedVector2D center1(it->second.x, it->second.z); if (Geometry::PointIsInSquare(center1 - center, it->second.u, it->second.v, CFixedVector2D(it->second.hw + clearance, it->second.hh + clearance))) { if (out) out->push_back(it->second.entity); else return true; } } if (out) return !out->empty(); // collided if the list isn't empty else return false; // didn't collide, if we got this far } void CCmpObstructionManager::Rasterize(Grid& grid, const std::vector& passClasses, bool fullUpdate) { PROFILE3("Rasterize Obstructions"); // Cells are only marked as blocked if the whole cell is strictly inside the shape. // (That ensures the shape's geometric border is always reachable.) // Pass classes will get shapes rasterized on them depending on their Obstruction value. // Classes with another value than "pathfinding" should not use Clearance. std::map pathfindingMasks; u16 foundationMask = 0; for (const PathfinderPassability& passability : passClasses) { switch (passability.m_Obstructions) { case PathfinderPassability::PATHFINDING: { std::map::iterator it = pathfindingMasks.find(passability.m_Clearance); if (it == pathfindingMasks.end()) pathfindingMasks[passability.m_Clearance] = passability.m_Mask; else it->second |= passability.m_Mask; break; } case PathfinderPassability::FOUNDATION: foundationMask |= passability.m_Mask; break; default: continue; } } // FLAG_BLOCK_PATHFINDING and FLAG_BLOCK_FOUNDATION are the only flags taken into account by MakeDirty* functions, // so they should be the only ones rasterized using with the help of m_Dirty*Shapes vectors. for (auto& maskPair : pathfindingMasks) RasterizeHelper(grid, FLAG_BLOCK_PATHFINDING, fullUpdate, maskPair.second, maskPair.first); RasterizeHelper(grid, FLAG_BLOCK_FOUNDATION, fullUpdate, foundationMask); m_DirtyStaticShapes.clear(); m_DirtyUnitShapes.clear(); } void CCmpObstructionManager::RasterizeHelper(Grid& grid, ICmpObstructionManager::flags_t requireMask, bool fullUpdate, pass_class_t appliedMask, entity_pos_t clearance) const { for (auto& pair : m_StaticShapes) { const StaticShape& shape = pair.second; if (!(shape.flags & requireMask)) continue; if (!fullUpdate && std::find(m_DirtyStaticShapes.begin(), m_DirtyStaticShapes.end(), pair.first) == m_DirtyStaticShapes.end()) continue; // TODO: it might be nice to rasterize with rounded corners for large 'expand' values. ObstructionSquare square = { shape.x, shape.z, shape.u, shape.v, shape.hw, shape.hh }; SimRasterize::Spans spans; SimRasterize::RasterizeRectWithClearance(spans, square, clearance, Pathfinding::NAVCELL_SIZE); for (SimRasterize::Span& span : spans) { i16 j = Clamp(span.j, (i16)0, (i16)(grid.m_H-1)); i16 i0 = std::max(span.i0, (i16)0); i16 i1 = std::min(span.i1, (i16)grid.m_W); for (i16 i = i0; i < i1; ++i) grid.set(i, j, grid.get(i, j) | appliedMask); } } for (auto& pair : m_UnitShapes) { if (!(pair.second.flags & requireMask)) continue; if (!fullUpdate && std::find(m_DirtyUnitShapes.begin(), m_DirtyUnitShapes.end(), pair.first) == m_DirtyUnitShapes.end()) continue; CFixedVector2D center(pair.second.x, pair.second.z); entity_pos_t r = pair.second.clearance + clearance; u16 i0, j0, i1, j1; Pathfinding::NearestNavcell(center.X - r, center.Y - r, i0, j0, grid.m_W, grid.m_H); Pathfinding::NearestNavcell(center.X + r, center.Y + r, i1, j1, grid.m_W, grid.m_H); for (u16 j = j0+1; j < j1; ++j) for (u16 i = i0+1; i < i1; ++i) grid.set(i, j, grid.get(i, j) | appliedMask); } } void CCmpObstructionManager::GetObstructionsInRange(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, std::vector& squares) const { GetUnitObstructionsInRange(filter, x0, z0, x1, z1, squares); GetStaticObstructionsInRange(filter, x0, z0, x1, z1, squares); } void CCmpObstructionManager::GetUnitObstructionsInRange(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, std::vector& squares) const { PROFILE("GetObstructionsInRange"); ENSURE(x0 <= x1 && z0 <= z1); std::vector unitShapes; m_UnitSubdivision.GetInRange(unitShapes, CFixedVector2D(x0, z0), CFixedVector2D(x1, z1)); for (entity_id_t& unitShape : unitShapes) { std::map::const_iterator it = m_UnitShapes.find(unitShape); ENSURE(it != m_UnitShapes.end()); if (!filter.TestShape(UNIT_INDEX_TO_TAG(it->first), it->second.flags, it->second.group, INVALID_ENTITY)) continue; entity_pos_t c = it->second.clearance; // Skip this object if it's completely outside the requested range if (it->second.x + c < x0 || it->second.x - c > x1 || it->second.z + c < z0 || it->second.z - c > z1) continue; CFixedVector2D u(entity_pos_t::FromInt(1), entity_pos_t::Zero()); CFixedVector2D v(entity_pos_t::Zero(), entity_pos_t::FromInt(1)); squares.emplace_back(ObstructionSquare{ it->second.x, it->second.z, u, v, c, c }); } } void CCmpObstructionManager::GetStaticObstructionsInRange(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, std::vector& squares) const { PROFILE("GetObstructionsInRange"); ENSURE(x0 <= x1 && z0 <= z1); std::vector staticShapes; m_StaticSubdivision.GetInRange(staticShapes, CFixedVector2D(x0, z0), CFixedVector2D(x1, z1)); for (entity_id_t& staticShape : staticShapes) { std::map::const_iterator it = m_StaticShapes.find(staticShape); ENSURE(it != m_StaticShapes.end()); if (!filter.TestShape(STATIC_INDEX_TO_TAG(it->first), it->second.flags, it->second.group, it->second.group2)) continue; entity_pos_t r = it->second.hw + it->second.hh; // overestimate the max dist of an edge from the center // Skip this object if its overestimated bounding box is completely outside the requested range if (it->second.x + r < x0 || it->second.x - r > x1 || it->second.z + r < z0 || it->second.z - r > z1) continue; // TODO: maybe we should use Geometry::GetHalfBoundingBox to be more precise? squares.emplace_back(ObstructionSquare{ it->second.x, it->second.z, it->second.u, it->second.v, it->second.hw, it->second.hh }); } } void CCmpObstructionManager::GetUnitsOnObstruction(const ObstructionSquare& square, std::vector& out, const IObstructionTestFilter& filter, bool strict) const { PROFILE("GetUnitsOnObstruction"); // In order to avoid getting units on impassable cells, we want to find all // units subject to the RasterizeRectWithClearance of the building's shape with the // unit's clearance covers the navcell the unit is on. std::vector unitShapes; CFixedVector2D center(square.x, square.z); CFixedVector2D expandedBox = Geometry::GetHalfBoundingBox(square.u, square.v, CFixedVector2D(square.hw, square.hh)) + CFixedVector2D(m_MaxClearance, m_MaxClearance); m_UnitSubdivision.GetInRange(unitShapes, center - expandedBox, center + expandedBox); std::map rasterizedRects; for (const u32& unitShape : unitShapes) { std::map::const_iterator it = m_UnitShapes.find(unitShape); ENSURE(it != m_UnitShapes.end()); const UnitShape& shape = it->second; if (!filter.TestShape(UNIT_INDEX_TO_TAG(unitShape), shape.flags, shape.group, INVALID_ENTITY)) continue; if (rasterizedRects.find(shape.clearance) == rasterizedRects.end()) { // The rasterization is an approximation of the real shapes. // Depending on your use, you may want to be more or less strict on the rasterization, // ie this may either return some units that aren't actually on the shape (if strict is set) // or this may not return some units that are on the shape (if strict is not set). // Foundations need to be non-strict, as otherwise it sometimes detects the builder units // as being on the shape, so it orders them away. SimRasterize::Spans& newSpans = rasterizedRects[shape.clearance]; if (strict) SimRasterize::RasterizeRectWithClearance(newSpans, square, shape.clearance, Pathfinding::NAVCELL_SIZE); else SimRasterize::RasterizeRectWithClearance(newSpans, square, shape.clearance-Pathfinding::CLEARANCE_EXTENSION_RADIUS, Pathfinding::NAVCELL_SIZE); } SimRasterize::Spans& spans = rasterizedRects[shape.clearance]; // Check whether the unit's center is on a navcell that's in // any of the spans u16 i = (shape.x / Pathfinding::NAVCELL_SIZE).ToInt_RoundToNegInfinity(); u16 j = (shape.z / Pathfinding::NAVCELL_SIZE).ToInt_RoundToNegInfinity(); for (const SimRasterize::Span& span : spans) { if (j == span.j && span.i0 <= i && i < span.i1) { out.push_back(shape.entity); break; } } } } void CCmpObstructionManager::GetStaticObstructionsOnObstruction(const ObstructionSquare& square, std::vector& out, const IObstructionTestFilter& filter) const { PROFILE("GetStaticObstructionsOnObstruction"); std::vector staticShapes; CFixedVector2D center(square.x, square.z); CFixedVector2D expandedBox = Geometry::GetHalfBoundingBox(square.u, square.v, CFixedVector2D(square.hw, square.hh)); m_StaticSubdivision.GetInRange(staticShapes, center - expandedBox, center + expandedBox); for (const u32& staticShape : staticShapes) { std::map::const_iterator it = m_StaticShapes.find(staticShape); ENSURE(it != m_StaticShapes.end()); const StaticShape& shape = it->second; if (!filter.TestShape(STATIC_INDEX_TO_TAG(staticShape), shape.flags, shape.group, shape.group2)) continue; if (Geometry::TestSquareSquare( center, square.u, square.v, CFixedVector2D(square.hw, square.hh), CFixedVector2D(shape.x, shape.z), shape.u, shape.v, CFixedVector2D(shape.hw, shape.hh))) { out.push_back(shape.entity); } } } void CCmpObstructionManager::RenderSubmit(SceneCollector& collector) { if (!m_DebugOverlayEnabled) return; CColor defaultColor(0, 0, 1, 1); CColor movingColor(1, 0, 1, 1); CColor boundsColor(1, 1, 0, 1); // If the shapes have changed, then regenerate all the overlays if (m_DebugOverlayDirty) { m_DebugOverlayLines.clear(); m_DebugOverlayLines.push_back(SOverlayLine()); m_DebugOverlayLines.back().m_Color = boundsColor; SimRender::ConstructSquareOnGround(GetSimContext(), (m_WorldX0+m_WorldX1).ToFloat()/2.f, (m_WorldZ0+m_WorldZ1).ToFloat()/2.f, (m_WorldX1-m_WorldX0).ToFloat(), (m_WorldZ1-m_WorldZ0).ToFloat(), 0, m_DebugOverlayLines.back(), true); for (std::map::iterator it = m_UnitShapes.begin(); it != m_UnitShapes.end(); ++it) { m_DebugOverlayLines.push_back(SOverlayLine()); m_DebugOverlayLines.back().m_Color = ((it->second.flags & FLAG_MOVING) ? movingColor : defaultColor); SimRender::ConstructSquareOnGround(GetSimContext(), it->second.x.ToFloat(), it->second.z.ToFloat(), it->second.clearance.ToFloat(), it->second.clearance.ToFloat(), 0, m_DebugOverlayLines.back(), true); } for (std::map::iterator it = m_StaticShapes.begin(); it != m_StaticShapes.end(); ++it) { m_DebugOverlayLines.push_back(SOverlayLine()); m_DebugOverlayLines.back().m_Color = defaultColor; float a = atan2f(it->second.v.X.ToFloat(), it->second.v.Y.ToFloat()); SimRender::ConstructSquareOnGround(GetSimContext(), it->second.x.ToFloat(), it->second.z.ToFloat(), it->second.hw.ToFloat()*2, it->second.hh.ToFloat()*2, a, m_DebugOverlayLines.back(), true); } m_DebugOverlayDirty = false; } for (size_t i = 0; i < m_DebugOverlayLines.size(); ++i) collector.Submit(&m_DebugOverlayLines[i]); } Index: ps/trunk/source/simulation2/components/CCmpRangeManager.cpp =================================================================== --- ps/trunk/source/simulation2/components/CCmpRangeManager.cpp (revision 26391) +++ ps/trunk/source/simulation2/components/CCmpRangeManager.cpp (revision 26392) @@ -1,2501 +1,2525 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * 0 A.D. is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with 0 A.D. If not, see . */ #include "precompiled.h" #include "simulation2/system/Component.h" #include "ICmpRangeManager.h" #include "ICmpTerrain.h" #include "simulation2/system/EntityMap.h" #include "simulation2/MessageTypes.h" #include "simulation2/components/ICmpFogging.h" #include "simulation2/components/ICmpMirage.h" #include "simulation2/components/ICmpOwnership.h" #include "simulation2/components/ICmpPosition.h" #include "simulation2/components/ICmpObstructionManager.h" #include "simulation2/components/ICmpTerritoryManager.h" #include "simulation2/components/ICmpVisibility.h" #include "simulation2/components/ICmpVision.h" #include "simulation2/components/ICmpWaterManager.h" #include "simulation2/helpers/Los.h" #include "simulation2/helpers/MapEdgeTiles.h" #include "simulation2/helpers/Render.h" #include "simulation2/helpers/Spatial.h" #include "simulation2/serialization/SerializedTypes.h" #include "graphics/Overlay.h" #include "lib/timer.h" #include "ps/CLogger.h" #include "ps/Profile.h" #include "renderer/Scene.h" #define DEBUG_RANGE_MANAGER_BOUNDS 0 namespace { /** * How many LOS vertices to have per region. * LOS regions are used to keep track of units. */ constexpr int LOS_REGION_RATIO = 8; /** * Tolerance for parabolic range calculations. * TODO C++20: change this to constexpr by fixing CFixed with std::is_constant_evaluated */ const fixed PARABOLIC_RANGE_TOLERANCE = fixed::FromInt(1)/2; /** * Convert an owner ID (-1 = unowned, 0 = gaia, 1..30 = players) * into a 32-bit mask for quick set-membership tests. */ u32 CalcOwnerMask(player_id_t owner) { if (owner >= -1 && owner < 31) return 1 << (1+owner); else return 0; // owner was invalid } /** * Returns LOS mask for given player. */ u32 CalcPlayerLosMask(player_id_t player) { if (player > 0 && player <= 16) return (u32)LosState::MASK << (2*(player-1)); return 0; } /** * Returns shared LOS mask for given list of players. */ u32 CalcSharedLosMask(std::vector players) { u32 playerMask = 0; for (size_t i = 0; i < players.size(); i++) playerMask |= CalcPlayerLosMask(players[i]); return playerMask; } /** * Add/remove a player to/from mask, which is a 1-bit mask representing a list of players. * Returns true if the mask is modified. */ bool SetPlayerSharedDirtyVisibilityBit(u16& mask, player_id_t player, bool enable) { if (player <= 0 || player > 16) return false; u16 oldMask = mask; if (enable) mask |= (0x1 << (player - 1)); else mask &= ~(0x1 << (player - 1)); return oldMask != mask; } /** * Computes the 2-bit visibility for one player, given the total 32-bit visibilities */ LosVisibility GetPlayerVisibility(u32 visibilities, player_id_t player) { if (player > 0 && player <= 16) return static_cast( (visibilities >> (2 *(player-1))) & 0x3 ); return LosVisibility::HIDDEN; } /** * Test whether the visibility is dirty for a given LoS region and a given player */ bool IsVisibilityDirty(u16 dirty, player_id_t player) { if (player > 0 && player <= 16) return (dirty >> (player - 1)) & 0x1; return false; } /** * Test whether a player share this vision */ bool HasVisionSharing(u16 visionSharing, player_id_t player) { return (visionSharing & (1 << (player - 1))) != 0; } /** * Computes the shared vision mask for the player */ u16 CalcVisionSharingMask(player_id_t player) { return 1 << (player-1); } /** * Representation of a range query. */ struct Query { std::vector lastMatch; CEntityHandle source; // TODO: this could crash if an entity is destroyed while a Query is still referencing it entity_pos_t minRange; entity_pos_t maxRange; - entity_pos_t elevationBonus; // Used for parabolas only. + entity_pos_t yOrigin; // Used for parabolas only. u32 ownersMask; i32 interface; u8 flagsMask; bool enabled; bool parabolic; bool accountForSize; // If true, the query accounts for unit sizes, otherwise it treats all entities as points. }; /** * Checks whether v is in a parabolic range of (0,0,0) * The highest point of the paraboloid is (0,range/2,0) * and the circle of distance 'range' around (0,0,0) on height y=0 is part of the paraboloid * This equates to computing f(x, z) = y = -(xx + zz)/(2*range) + range/2 > 0, * or alternatively sqrt(xx+zz) <= sqrt(range^2 - 2range*y). * * Avoids sqrting and overflowing. */ static bool InParabolicRange(CFixedVector3D v, fixed range) { u64 xx = SQUARE_U64_FIXED(v.X); // xx <= 2^62 u64 zz = SQUARE_U64_FIXED(v.Z); i64 d2 = (xx + zz) >> 1; // d2 <= 2^62 (no overflow) i32 y = v.Y.GetInternalValue(); i32 c = range.GetInternalValue(); i32 c_2 = c >> 1; i64 c2 = MUL_I64_I32_I32(c_2 - y, c); return d2 <= c2; } struct EntityParabolicRangeOutline { entity_id_t source; CFixedVector3D position; entity_pos_t range; std::vector outline; }; static std::map ParabolicRangesOutlines; /** * Representation of an entity, with the data needed for queries. */ enum FlagMasks { // flags used for queries None = 0x00, Normal = 0x01, Injured = 0x02, AllQuery = Normal | Injured, // 0x04 reserved for future use // general flags InWorld = 0x08, RetainInFog = 0x10, RevealShore = 0x20, ScriptedVisibility = 0x40, SharedVision = 0x80 }; struct EntityData { EntityData() : visibilities(0), size(0), visionSharing(0), owner(-1), flags(FlagMasks::Normal) { } entity_pos_t x, z; entity_pos_t visionRange; u32 visibilities; // 2-bit visibility, per player u32 size; u16 visionSharing; // 1-bit per player i8 owner; u8 flags; // See the FlagMasks enum template inline bool HasFlag() const { return (flags & mask) != 0; } template inline void SetFlag(bool val) { flags = val ? (flags | mask) : (flags & ~mask); } inline void SetFlag(u8 mask, bool val) { flags = val ? (flags | mask) : (flags & ~mask); } }; static_assert(sizeof(EntityData) == 24); /** * Functor for sorting entities by distance from a source point. * It must only be passed entities that are in 'entities' * and are currently in the world. */ class EntityDistanceOrdering { public: EntityDistanceOrdering(const EntityMap& entities, const CFixedVector2D& source) : m_EntityData(entities), m_Source(source) { } EntityDistanceOrdering(const EntityDistanceOrdering& entity) = default; bool operator()(entity_id_t a, entity_id_t b) const { const EntityData& da = m_EntityData.find(a)->second; const EntityData& db = m_EntityData.find(b)->second; CFixedVector2D vecA = CFixedVector2D(da.x, da.z) - m_Source; CFixedVector2D vecB = CFixedVector2D(db.x, db.z) - m_Source; return (vecA.CompareLength(vecB) < 0); } const EntityMap& m_EntityData; CFixedVector2D m_Source; private: EntityDistanceOrdering& operator=(const EntityDistanceOrdering&); }; } // anonymous namespace /** * Serialization helper template for Query */ template<> struct SerializeHelper { template void Common(S& serialize, const char* UNUSED(name), Serialize::qualify value) { serialize.NumberFixed_Unbounded("min range", value.minRange); serialize.NumberFixed_Unbounded("max range", value.maxRange); - serialize.NumberFixed_Unbounded("elevation bonus", value.elevationBonus); + serialize.NumberFixed_Unbounded("yOrigin", value.yOrigin); serialize.NumberU32_Unbounded("owners mask", value.ownersMask); serialize.NumberI32_Unbounded("interface", value.interface); Serializer(serialize, "last match", value.lastMatch); serialize.NumberU8_Unbounded("flagsMask", value.flagsMask); serialize.Bool("enabled", value.enabled); serialize.Bool("parabolic",value.parabolic); serialize.Bool("account for size",value.accountForSize); } void operator()(ISerializer& serialize, const char* name, Query& value, const CSimContext& UNUSED(context)) { Common(serialize, name, value); uint32_t id = value.source.GetId(); serialize.NumberU32_Unbounded("source", id); } void operator()(IDeserializer& deserialize, const char* name, Query& value, const CSimContext& context) { Common(deserialize, name, value); uint32_t id; deserialize.NumberU32_Unbounded("source", id); value.source = context.GetComponentManager().LookupEntityHandle(id, true); // the referenced entity might not have been deserialized yet, // so tell LookupEntityHandle to allocate the handle if necessary } }; /** * Serialization helper template for EntityData */ template<> struct SerializeHelper { template void operator()(S& serialize, const char* UNUSED(name), Serialize::qualify value) { serialize.NumberFixed_Unbounded("x", value.x); serialize.NumberFixed_Unbounded("z", value.z); serialize.NumberFixed_Unbounded("vision", value.visionRange); serialize.NumberU32_Unbounded("visibilities", value.visibilities); serialize.NumberU32_Unbounded("size", value.size); serialize.NumberU16_Unbounded("vision sharing", value.visionSharing); serialize.NumberI8_Unbounded("owner", value.owner); serialize.NumberU8_Unbounded("flags", value.flags); } }; /** * Range manager implementation. * Maintains a list of all entities (and their positions and owners), which is used for * queries. * * LOS implementation is based on the model described in GPG2. * (TODO: would be nice to make it cleverer, so e.g. mountains and walls * can block vision) */ class CCmpRangeManager : public ICmpRangeManager { public: static void ClassInit(CComponentManager& componentManager) { componentManager.SubscribeGloballyToMessageType(MT_Create); componentManager.SubscribeGloballyToMessageType(MT_PositionChanged); componentManager.SubscribeGloballyToMessageType(MT_OwnershipChanged); componentManager.SubscribeGloballyToMessageType(MT_Destroy); componentManager.SubscribeGloballyToMessageType(MT_VisionRangeChanged); componentManager.SubscribeGloballyToMessageType(MT_VisionSharingChanged); componentManager.SubscribeToMessageType(MT_Deserialized); componentManager.SubscribeToMessageType(MT_Update); componentManager.SubscribeToMessageType(MT_RenderSubmit); // for debug overlays } DEFAULT_COMPONENT_ALLOCATOR(RangeManager) bool m_DebugOverlayEnabled; bool m_DebugOverlayDirty; std::vector m_DebugOverlayLines; // Deserialization flag. A lot of different functions are called by Deserialize() // and we don't want to pass isDeserializing bool arguments to all of them... bool m_Deserializing; // World bounds (entities are expected to be within this range) entity_pos_t m_WorldX0; entity_pos_t m_WorldZ0; entity_pos_t m_WorldX1; entity_pos_t m_WorldZ1; // Range query state: tag_t m_QueryNext; // next allocated id std::map m_Queries; EntityMap m_EntityData; FastSpatialSubdivision m_Subdivision; // spatial index of m_EntityData std::vector m_SubdivisionResults; // LOS state: static const player_id_t MAX_LOS_PLAYER_ID = 16; using LosRegion = std::pair; std::array m_LosRevealAll; bool m_LosCircular; i32 m_LosVerticesPerSide; // Cache for visibility tracking i32 m_LosRegionsPerSide; bool m_GlobalVisibilityUpdate; std::array m_GlobalPlayerVisibilityUpdate; Grid m_DirtyVisibility; Grid> m_LosRegions; // List of entities that must be updated, regardless of the status of their tile std::vector m_ModifiedEntities; // Counts of units seeing vertex, per vertex, per player (starting with player 0). // Use u16 to avoid overflows when we have very large (but not infeasibly large) numbers // of units in a very small area. // (Note we use vertexes, not tiles, to better match the renderer.) // Lazily constructed when it's needed, to save memory in smaller games. std::array, MAX_LOS_PLAYER_ID> m_LosPlayerCounts; // 2-bit LosState per player, starting with player 1 (not 0!) up to player MAX_LOS_PLAYER_ID (inclusive) Grid m_LosState; // Special static visibility data for the "reveal whole map" mode // (TODO: this is usually a waste of memory) Grid m_LosStateRevealed; // Shared LOS masks, one per player. std::array m_SharedLosMasks; // Shared dirty visibility masks, one per player. std::array m_SharedDirtyVisibilityMasks; // Cache explored vertices per player (not serialized) u32 m_TotalInworldVertices; std::vector m_ExploredVertices; static std::string GetSchema() { return ""; } virtual void Init(const CParamNode& UNUSED(paramNode)) { m_QueryNext = 1; m_DebugOverlayEnabled = false; m_DebugOverlayDirty = true; m_Deserializing = false; m_WorldX0 = m_WorldZ0 = m_WorldX1 = m_WorldZ1 = entity_pos_t::Zero(); // Initialise with bogus values (these will get replaced when // SetBounds is called) ResetSubdivisions(entity_pos_t::FromInt(1024), entity_pos_t::FromInt(1024)); m_SubdivisionResults.reserve(4096); // The whole map should be visible to Gaia by default, else e.g. animals // will get confused when trying to run from enemies m_LosRevealAll[0] = true; m_GlobalVisibilityUpdate = true; m_LosCircular = false; m_LosVerticesPerSide = 0; } virtual void Deinit() { } template void SerializeCommon(S& serialize) { serialize.NumberFixed_Unbounded("world x0", m_WorldX0); serialize.NumberFixed_Unbounded("world z0", m_WorldZ0); serialize.NumberFixed_Unbounded("world x1", m_WorldX1); serialize.NumberFixed_Unbounded("world z1", m_WorldZ1); serialize.NumberU32_Unbounded("query next", m_QueryNext); Serializer(serialize, "queries", m_Queries, GetSimContext()); Serializer(serialize, "entity data", m_EntityData); Serializer(serialize, "los reveal all", m_LosRevealAll); serialize.Bool("los circular", m_LosCircular); serialize.NumberI32_Unbounded("los verts per side", m_LosVerticesPerSide); serialize.Bool("global visibility update", m_GlobalVisibilityUpdate); Serializer(serialize, "global player visibility update", m_GlobalPlayerVisibilityUpdate); Serializer(serialize, "dirty visibility", m_DirtyVisibility); Serializer(serialize, "modified entities", m_ModifiedEntities); // We don't serialize m_Subdivision, m_LosPlayerCounts or m_LosRegions // since they can be recomputed from the entity data when deserializing; // m_LosState must be serialized since it depends on the history of exploration Serializer(serialize, "los state", m_LosState); Serializer(serialize, "shared los masks", m_SharedLosMasks); Serializer(serialize, "shared dirty visibility masks", m_SharedDirtyVisibilityMasks); } virtual void Serialize(ISerializer& serialize) { SerializeCommon(serialize); } virtual void Deserialize(const CParamNode& paramNode, IDeserializer& deserialize) { Init(paramNode); SerializeCommon(deserialize); } virtual void HandleMessage(const CMessage& msg, bool UNUSED(global)) { switch (msg.GetType()) { case MT_Deserialized: { // Reinitialize subdivisions and LOS data after all // other components have been deserialized. m_Deserializing = true; ResetDerivedData(); m_Deserializing = false; break; } case MT_Create: { const CMessageCreate& msgData = static_cast (msg); entity_id_t ent = msgData.entity; // Ignore local entities - we shouldn't let them influence anything if (ENTITY_IS_LOCAL(ent)) break; // Ignore non-positional entities CmpPtr cmpPosition(GetSimContext(), ent); if (!cmpPosition) break; // The newly-created entity will have owner -1 and position out-of-world // (any initialisation of those values will happen later), so we can just // use the default-constructed EntityData here EntityData entdata; // Store the LOS data, if any CmpPtr cmpVision(GetSimContext(), ent); if (cmpVision) { entdata.visionRange = cmpVision->GetRange(); entdata.SetFlag(cmpVision->GetRevealShore()); } CmpPtr cmpVisibility(GetSimContext(), ent); if (cmpVisibility) entdata.SetFlag(cmpVisibility->GetRetainInFog()); // Store the size CmpPtr cmpObstruction(GetSimContext(), ent); if (cmpObstruction) entdata.size = cmpObstruction->GetSize().ToInt_RoundToInfinity(); // Remember this entity m_EntityData.insert(ent, entdata); break; } case MT_PositionChanged: { const CMessagePositionChanged& msgData = static_cast (msg); entity_id_t ent = msgData.entity; EntityMap::iterator it = m_EntityData.find(ent); // Ignore if we're not already tracking this entity if (it == m_EntityData.end()) break; if (msgData.inWorld) { if (it->second.HasFlag()) { CFixedVector2D from(it->second.x, it->second.z); CFixedVector2D to(msgData.x, msgData.z); m_Subdivision.Move(ent, from, to, it->second.size); if (it->second.HasFlag()) SharingLosMove(it->second.visionSharing, it->second.visionRange, from, to); else LosMove(it->second.owner, it->second.visionRange, from, to); LosRegion oldLosRegion = PosToLosRegionsHelper(it->second.x, it->second.z); LosRegion newLosRegion = PosToLosRegionsHelper(msgData.x, msgData.z); if (oldLosRegion != newLosRegion) { RemoveFromRegion(oldLosRegion, ent); AddToRegion(newLosRegion, ent); } } else { CFixedVector2D to(msgData.x, msgData.z); m_Subdivision.Add(ent, to, it->second.size); if (it->second.HasFlag()) SharingLosAdd(it->second.visionSharing, it->second.visionRange, to); else LosAdd(it->second.owner, it->second.visionRange, to); AddToRegion(PosToLosRegionsHelper(msgData.x, msgData.z), ent); } it->second.SetFlag(true); it->second.x = msgData.x; it->second.z = msgData.z; } else { if (it->second.HasFlag()) { CFixedVector2D from(it->second.x, it->second.z); m_Subdivision.Remove(ent, from, it->second.size); if (it->second.HasFlag()) SharingLosRemove(it->second.visionSharing, it->second.visionRange, from); else LosRemove(it->second.owner, it->second.visionRange, from); RemoveFromRegion(PosToLosRegionsHelper(it->second.x, it->second.z), ent); } it->second.SetFlag(false); it->second.x = entity_pos_t::Zero(); it->second.z = entity_pos_t::Zero(); } RequestVisibilityUpdate(ent); break; } case MT_OwnershipChanged: { const CMessageOwnershipChanged& msgData = static_cast (msg); entity_id_t ent = msgData.entity; EntityMap::iterator it = m_EntityData.find(ent); // Ignore if we're not already tracking this entity if (it == m_EntityData.end()) break; if (it->second.HasFlag()) { // Entity vision is taken into account in VisionSharingChanged // when sharing component activated if (!it->second.HasFlag()) { CFixedVector2D pos(it->second.x, it->second.z); LosRemove(it->second.owner, it->second.visionRange, pos); LosAdd(msgData.to, it->second.visionRange, pos); } if (it->second.HasFlag()) { RevealShore(it->second.owner, false); RevealShore(msgData.to, true); } } ENSURE(-128 <= msgData.to && msgData.to <= 127); it->second.owner = (i8)msgData.to; break; } case MT_Destroy: { const CMessageDestroy& msgData = static_cast (msg); entity_id_t ent = msgData.entity; EntityMap::iterator it = m_EntityData.find(ent); // Ignore if we're not already tracking this entity if (it == m_EntityData.end()) break; if (it->second.HasFlag()) { m_Subdivision.Remove(ent, CFixedVector2D(it->second.x, it->second.z), it->second.size); RemoveFromRegion(PosToLosRegionsHelper(it->second.x, it->second.z), ent); } // This will be called after Ownership's OnDestroy, so ownership will be set // to -1 already and we don't have to do a LosRemove here ENSURE(it->second.owner == -1); m_EntityData.erase(it); break; } case MT_VisionRangeChanged: { const CMessageVisionRangeChanged& msgData = static_cast (msg); entity_id_t ent = msgData.entity; EntityMap::iterator it = m_EntityData.find(ent); // Ignore if we're not already tracking this entity if (it == m_EntityData.end()) break; CmpPtr cmpVision(GetSimContext(), ent); if (!cmpVision) break; entity_pos_t oldRange = it->second.visionRange; entity_pos_t newRange = msgData.newRange; // If the range changed and the entity's in-world, we need to manually adjust it // but if it's not in-world, we only need to set the new vision range it->second.visionRange = newRange; if (it->second.HasFlag()) { CFixedVector2D pos(it->second.x, it->second.z); if (it->second.HasFlag()) { SharingLosRemove(it->second.visionSharing, oldRange, pos); SharingLosAdd(it->second.visionSharing, newRange, pos); } else { LosRemove(it->second.owner, oldRange, pos); LosAdd(it->second.owner, newRange, pos); } } break; } case MT_VisionSharingChanged: { const CMessageVisionSharingChanged& msgData = static_cast (msg); entity_id_t ent = msgData.entity; EntityMap::iterator it = m_EntityData.find(ent); // Ignore if we're not already tracking this entity if (it == m_EntityData.end()) break; ENSURE(msgData.player > 0 && msgData.player < MAX_LOS_PLAYER_ID+1); u16 visionChanged = CalcVisionSharingMask(msgData.player); if (!it->second.HasFlag()) { // Activation of the Vision Sharing ENSURE(it->second.owner == (i8)msgData.player); it->second.visionSharing = visionChanged; it->second.SetFlag(true); break; } if (it->second.HasFlag()) { entity_pos_t range = it->second.visionRange; CFixedVector2D pos(it->second.x, it->second.z); if (msgData.add) LosAdd(msgData.player, range, pos); else LosRemove(msgData.player, range, pos); } if (msgData.add) it->second.visionSharing |= visionChanged; else it->second.visionSharing &= ~visionChanged; break; } case MT_Update: { m_DebugOverlayDirty = true; ExecuteActiveQueries(); UpdateVisibilityData(); break; } case MT_RenderSubmit: { const CMessageRenderSubmit& msgData = static_cast (msg); RenderSubmit(msgData.collector); break; } } } virtual void SetBounds(entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1) { // Don't support rectangular looking maps. ENSURE(x1-x0 == z1-z0); m_WorldX0 = x0; m_WorldZ0 = z0; m_WorldX1 = x1; m_WorldZ1 = z1; m_LosVerticesPerSide = ((x1 - x0) / LOS_TILE_SIZE).ToInt_RoundToZero() + 1; ResetDerivedData(); } virtual void Verify() { // Ignore if map not initialised yet if (m_WorldX1.IsZero()) return; // Check that calling ResetDerivedData (i.e. recomputing all the state from scratch) // does not affect the incrementally-computed state std::array, MAX_LOS_PLAYER_ID> oldPlayerCounts = m_LosPlayerCounts; Grid oldStateRevealed = m_LosStateRevealed; FastSpatialSubdivision oldSubdivision = m_Subdivision; Grid > oldLosRegions = m_LosRegions; m_Deserializing = true; ResetDerivedData(); m_Deserializing = false; if (oldPlayerCounts != m_LosPlayerCounts) { for (size_t id = 0; id < m_LosPlayerCounts.size(); ++id) { debug_printf("player %zu\n", id); for (size_t i = 0; i < oldPlayerCounts[id].width(); ++i) { for (size_t j = 0; j < oldPlayerCounts[id].height(); ++j) debug_printf("%i ", oldPlayerCounts[id].get(i,j)); debug_printf("\n"); } } for (size_t id = 0; id < m_LosPlayerCounts.size(); ++id) { debug_printf("player %zu\n", id); for (size_t i = 0; i < m_LosPlayerCounts[id].width(); ++i) { for (size_t j = 0; j < m_LosPlayerCounts[id].height(); ++j) debug_printf("%i ", m_LosPlayerCounts[id].get(i,j)); debug_printf("\n"); } } debug_warn(L"inconsistent player counts"); } if (oldStateRevealed != m_LosStateRevealed) debug_warn(L"inconsistent revealed"); if (oldSubdivision != m_Subdivision) debug_warn(L"inconsistent subdivs"); if (oldLosRegions != m_LosRegions) debug_warn(L"inconsistent los regions"); } FastSpatialSubdivision* GetSubdivision() { return &m_Subdivision; } // Reinitialise subdivisions and LOS data, based on entity data void ResetDerivedData() { ENSURE(m_WorldX0.IsZero() && m_WorldZ0.IsZero()); // don't bother implementing non-zero offsets yet ResetSubdivisions(m_WorldX1, m_WorldZ1); m_LosRegionsPerSide = m_LosVerticesPerSide / LOS_REGION_RATIO; for (size_t player_id = 0; player_id < m_LosPlayerCounts.size(); ++player_id) m_LosPlayerCounts[player_id].clear(); m_ExploredVertices.clear(); m_ExploredVertices.resize(MAX_LOS_PLAYER_ID+1, 0); if (m_Deserializing) { // recalc current exploration stats. for (i32 j = 0; j < m_LosVerticesPerSide; j++) for (i32 i = 0; i < m_LosVerticesPerSide; i++) if (!LosIsOffWorld(i, j)) for (u8 k = 1; k < MAX_LOS_PLAYER_ID+1; ++k) m_ExploredVertices.at(k) += ((m_LosState.get(i, j) & ((u32)LosState::EXPLORED << (2*(k-1)))) > 0); } else m_LosState.resize(m_LosVerticesPerSide, m_LosVerticesPerSide); m_LosStateRevealed.resize(m_LosVerticesPerSide, m_LosVerticesPerSide); if (!m_Deserializing) { m_DirtyVisibility.resize(m_LosRegionsPerSide, m_LosRegionsPerSide); } ENSURE(m_DirtyVisibility.width() == m_LosRegionsPerSide); ENSURE(m_DirtyVisibility.height() == m_LosRegionsPerSide); m_LosRegions.resize(m_LosRegionsPerSide, m_LosRegionsPerSide); for (EntityMap::const_iterator it = m_EntityData.begin(); it != m_EntityData.end(); ++it) if (it->second.HasFlag()) { if (it->second.HasFlag()) SharingLosAdd(it->second.visionSharing, it->second.visionRange, CFixedVector2D(it->second.x, it->second.z)); else LosAdd(it->second.owner, it->second.visionRange, CFixedVector2D(it->second.x, it->second.z)); AddToRegion(PosToLosRegionsHelper(it->second.x, it->second.z), it->first); if (it->second.HasFlag()) RevealShore(it->second.owner, true); } m_TotalInworldVertices = 0; for (i32 j = 0; j < m_LosVerticesPerSide; ++j) for (i32 i = 0; i < m_LosVerticesPerSide; ++i) { if (LosIsOffWorld(i,j)) m_LosStateRevealed.get(i, j) = 0; else { m_LosStateRevealed.get(i, j) = 0xFFFFFFFFu; m_TotalInworldVertices++; } } } void ResetSubdivisions(entity_pos_t x1, entity_pos_t z1) { m_Subdivision.Reset(x1, z1); for (EntityMap::const_iterator it = m_EntityData.begin(); it != m_EntityData.end(); ++it) if (it->second.HasFlag()) m_Subdivision.Add(it->first, CFixedVector2D(it->second.x, it->second.z), it->second.size); } virtual tag_t CreateActiveQuery(entity_id_t source, entity_pos_t minRange, entity_pos_t maxRange, const std::vector& owners, int requiredInterface, u8 flags, bool accountForSize) { tag_t id = m_QueryNext++; m_Queries[id] = ConstructQuery(source, minRange, maxRange, owners, requiredInterface, flags, accountForSize); return id; } virtual tag_t CreateActiveParabolicQuery(entity_id_t source, - entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t elevationBonus, + entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t yOrigin, const std::vector& owners, int requiredInterface, u8 flags) { tag_t id = m_QueryNext++; - m_Queries[id] = ConstructParabolicQuery(source, minRange, maxRange, elevationBonus, owners, requiredInterface, flags, true); + m_Queries[id] = ConstructParabolicQuery(source, minRange, maxRange, yOrigin, owners, requiredInterface, flags, true); return id; } virtual void DestroyActiveQuery(tag_t tag) { if (m_Queries.find(tag) == m_Queries.end()) { LOGERROR("CCmpRangeManager: DestroyActiveQuery called with invalid tag %u", tag); return; } m_Queries.erase(tag); } virtual void EnableActiveQuery(tag_t tag) { std::map::iterator it = m_Queries.find(tag); if (it == m_Queries.end()) { LOGERROR("CCmpRangeManager: EnableActiveQuery called with invalid tag %u", tag); return; } Query& q = it->second; q.enabled = true; } virtual void DisableActiveQuery(tag_t tag) { std::map::iterator it = m_Queries.find(tag); if (it == m_Queries.end()) { LOGERROR("CCmpRangeManager: DisableActiveQuery called with invalid tag %u", tag); return; } Query& q = it->second; q.enabled = false; } virtual bool IsActiveQueryEnabled(tag_t tag) const { std::map::const_iterator it = m_Queries.find(tag); if (it == m_Queries.end()) { LOGERROR("CCmpRangeManager: IsActiveQueryEnabled called with invalid tag %u", tag); return false; } const Query& q = it->second; return q.enabled; } virtual std::vector ExecuteQueryAroundPos(const CFixedVector2D& pos, entity_pos_t minRange, entity_pos_t maxRange, const std::vector& owners, int requiredInterface, bool accountForSize) { Query q = ConstructQuery(INVALID_ENTITY, minRange, maxRange, owners, requiredInterface, GetEntityFlagMask("normal"), accountForSize); std::vector r; PerformQuery(q, r, pos); // Return the list sorted by distance from the entity std::stable_sort(r.begin(), r.end(), EntityDistanceOrdering(m_EntityData, pos)); return r; } virtual std::vector ExecuteQuery(entity_id_t source, entity_pos_t minRange, entity_pos_t maxRange, const std::vector& owners, int requiredInterface, bool accountForSize) { PROFILE("ExecuteQuery"); Query q = ConstructQuery(source, minRange, maxRange, owners, requiredInterface, GetEntityFlagMask("normal"), accountForSize); std::vector r; CmpPtr cmpSourcePosition(q.source); if (!cmpSourcePosition || !cmpSourcePosition->IsInWorld()) { // If the source doesn't have a position, then the result is just the empty list return r; } CFixedVector2D pos = cmpSourcePosition->GetPosition2D(); PerformQuery(q, r, pos); // Return the list sorted by distance from the entity std::stable_sort(r.begin(), r.end(), EntityDistanceOrdering(m_EntityData, pos)); return r; } virtual std::vector ResetActiveQuery(tag_t tag) { PROFILE("ResetActiveQuery"); std::vector r; std::map::iterator it = m_Queries.find(tag); if (it == m_Queries.end()) { LOGERROR("CCmpRangeManager: ResetActiveQuery called with invalid tag %u", tag); return r; } Query& q = it->second; q.enabled = true; CmpPtr cmpSourcePosition(q.source); if (!cmpSourcePosition || !cmpSourcePosition->IsInWorld()) { // If the source doesn't have a position, then the result is just the empty list q.lastMatch = r; return r; } CFixedVector2D pos = cmpSourcePosition->GetPosition2D(); PerformQuery(q, r, pos); q.lastMatch = r; // Return the list sorted by distance from the entity std::stable_sort(r.begin(), r.end(), EntityDistanceOrdering(m_EntityData, pos)); return r; } virtual std::vector GetEntitiesByPlayer(player_id_t player) const { return GetEntitiesByMask(CalcOwnerMask(player)); } virtual std::vector GetNonGaiaEntities() const { return GetEntitiesByMask(~3u); // bit 0 for owner=-1 and bit 1 for gaia } virtual std::vector GetGaiaAndNonGaiaEntities() const { return GetEntitiesByMask(~1u); // bit 0 for owner=-1 } std::vector GetEntitiesByMask(u32 ownerMask) const { std::vector entities; for (EntityMap::const_iterator it = m_EntityData.begin(); it != m_EntityData.end(); ++it) { // Check owner and add to list if it matches if (CalcOwnerMask(it->second.owner) & ownerMask) entities.push_back(it->first); } return entities; } virtual void SetDebugOverlay(bool enabled) { m_DebugOverlayEnabled = enabled; m_DebugOverlayDirty = true; if (!enabled) m_DebugOverlayLines.clear(); } /** * Update all currently-enabled active queries. */ void ExecuteActiveQueries() { PROFILE3("ExecuteActiveQueries"); // Store a queue of all messages before sending any, so we can assume // no entities will move until we've finished checking all the ranges std::vector > messages; std::vector results; std::vector added; std::vector removed; for (std::map::iterator it = m_Queries.begin(); it != m_Queries.end(); ++it) { Query& query = it->second; if (!query.enabled) continue; results.clear(); CmpPtr cmpSourcePosition(query.source); if (cmpSourcePosition && cmpSourcePosition->IsInWorld()) { results.reserve(query.lastMatch.size()); PerformQuery(query, results, cmpSourcePosition->GetPosition2D()); } // Compute the changes vs the last match added.clear(); removed.clear(); // Return the 'added' list sorted by distance from the entity // (Don't bother sorting 'removed' because they might not even have positions or exist any more) std::set_difference(results.begin(), results.end(), query.lastMatch.begin(), query.lastMatch.end(), std::back_inserter(added)); std::set_difference(query.lastMatch.begin(), query.lastMatch.end(), results.begin(), results.end(), std::back_inserter(removed)); if (added.empty() && removed.empty()) continue; if (cmpSourcePosition && cmpSourcePosition->IsInWorld()) std::stable_sort(added.begin(), added.end(), EntityDistanceOrdering(m_EntityData, cmpSourcePosition->GetPosition2D())); messages.resize(messages.size() + 1); std::pair& back = messages.back(); back.first = query.source.GetId(); back.second.tag = it->first; back.second.added.swap(added); back.second.removed.swap(removed); query.lastMatch.swap(results); } CComponentManager& cmpMgr = GetSimContext().GetComponentManager(); for (size_t i = 0; i < messages.size(); ++i) cmpMgr.PostMessage(messages[i].first, messages[i].second); } /** * Returns whether the given entity matches the given query (ignoring maxRange) */ bool TestEntityQuery(const Query& q, entity_id_t id, const EntityData& entity) const { // Quick filter to ignore entities with the wrong owner if (!(CalcOwnerMask(entity.owner) & q.ownersMask)) return false; // Ignore entities not present in the world if (!entity.HasFlag()) return false; // Ignore entities that don't match the current flags if (!((entity.flags & FlagMasks::AllQuery) & q.flagsMask)) return false; // Ignore self if (id == q.source.GetId()) return false; // Ignore if it's missing the required interface if (q.interface && !GetSimContext().GetComponentManager().QueryInterface(id, q.interface)) return false; return true; } /** * Returns a list of distinct entity IDs that match the given query, sorted by ID. */ void PerformQuery(const Query& q, std::vector& r, CFixedVector2D pos) { - // Special case: range -1.0 means check all entities ignoring distance - if (q.maxRange == entity_pos_t::FromInt(-1)) + // Special case: range is ALWAYS_IN_RANGE means check all entities ignoring distance. + if (q.maxRange == ALWAYS_IN_RANGE) { for (EntityMap::const_iterator it = m_EntityData.begin(); it != m_EntityData.end(); ++it) { if (!TestEntityQuery(q, it->first, it->second)) continue; r.push_back(it->first); } } - // Not the entire world, so check a parabolic range, or a regular range + // Not the entire world, so check a parabolic range, or a regular range. else if (q.parabolic) { - // elevationBonus is part of the 3D position, as the source is really that much heigher + // The yOrigin is part of the 3D position, as the source is really that much heigher. CmpPtr cmpSourcePosition(q.source); CFixedVector3D pos3d = cmpSourcePosition->GetPosition()+ - CFixedVector3D(entity_pos_t::Zero(), q.elevationBonus, entity_pos_t::Zero()) ; - // Get a quick list of entities that are potentially in range, with a cutoff of 2*maxRange + CFixedVector3D(entity_pos_t::Zero(), q.yOrigin, entity_pos_t::Zero()) ; + // Get a quick list of entities that are potentially in range, with a cutoff of 2*maxRange. m_SubdivisionResults.clear(); m_Subdivision.GetNear(m_SubdivisionResults, pos, q.maxRange * 2); for (size_t i = 0; i < m_SubdivisionResults.size(); ++i) { EntityMap::const_iterator it = m_EntityData.find(m_SubdivisionResults[i]); ENSURE(it != m_EntityData.end()); if (!TestEntityQuery(q, it->first, it->second)) continue; CmpPtr cmpSecondPosition(GetSimContext(), m_SubdivisionResults[i]); if (!cmpSecondPosition || !cmpSecondPosition->IsInWorld()) continue; CFixedVector3D secondPosition = cmpSecondPosition->GetPosition(); // Doing an exact check for parabolas with obstruction sizes is not really possible. // However, we can prove that InParabolicRange(d, range + size) > InParabolicRange(d, range) // in the sense that it always returns true when the latter would, which is enough. // To do so, compute the derivative with respect to distance, and notice that // they have an intersection after which the former grows slower, and then use that to prove the above. // Note that this is only true because we do not account for vertical size here, // if we did, we would also need to artificially 'raise' the source over the target. entity_pos_t range = q.maxRange + (q.accountForSize ? fixed::FromInt(it->second.size) : fixed::Zero()); if (!InParabolicRange(CFixedVector3D(it->second.x, secondPosition.Y, it->second.z) - pos3d, range)) continue; if (!q.minRange.IsZero()) if ((CFixedVector2D(it->second.x, it->second.z) - pos).CompareLength(q.minRange) < 0) continue; r.push_back(it->first); } std::sort(r.begin(), r.end()); } // check a regular range (i.e. not the entire world, and not parabolic) else { // Get a quick list of entities that are potentially in range m_SubdivisionResults.clear(); m_Subdivision.GetNear(m_SubdivisionResults, pos, q.maxRange); for (size_t i = 0; i < m_SubdivisionResults.size(); ++i) { EntityMap::const_iterator it = m_EntityData.find(m_SubdivisionResults[i]); ENSURE(it != m_EntityData.end()); if (!TestEntityQuery(q, it->first, it->second)) continue; // Restrict based on approximate circle-circle distance. entity_pos_t range = q.maxRange + (q.accountForSize ? fixed::FromInt(it->second.size) : fixed::Zero()); if ((CFixedVector2D(it->second.x, it->second.z) - pos).CompareLength(range) > 0) continue; if (!q.minRange.IsZero()) if ((CFixedVector2D(it->second.x, it->second.z) - pos).CompareLength(q.minRange) < 0) continue; r.push_back(it->first); } std::sort(r.begin(), r.end()); } } - virtual entity_pos_t GetElevationAdaptedRange(const CFixedVector3D& pos1, const CFixedVector3D& rot, entity_pos_t range, entity_pos_t elevationBonus, entity_pos_t angle) const + virtual entity_pos_t GetEffectiveParabolicRange(entity_id_t source, entity_id_t target, entity_pos_t range, entity_pos_t yOrigin) const + { + // For non-positive ranges, just return the range. + if (range < entity_pos_t::Zero()) + return range; + + CmpPtr cmpSourcePosition(GetSimContext(), source); + if (!cmpSourcePosition || !cmpSourcePosition->IsInWorld()) + return NEVER_IN_RANGE; + + CmpPtr cmpTargetPosition(GetSimContext(), target); + if (!cmpTargetPosition || !cmpTargetPosition->IsInWorld()) + return NEVER_IN_RANGE; + + entity_pos_t heightDifference = cmpSourcePosition->GetHeightOffset() - cmpTargetPosition->GetHeightOffset() + yOrigin; + if (heightDifference < -range / 2) + return NEVER_IN_RANGE; + + entity_pos_t effectiveRange; + effectiveRange.SetInternalValue(static_cast(isqrt64(SQUARE_U64_FIXED(range) + static_cast(heightDifference.GetInternalValue()) * static_cast(range.GetInternalValue()) * 2))); + return effectiveRange; + } + + virtual entity_pos_t GetElevationAdaptedRange(const CFixedVector3D& pos1, const CFixedVector3D& rot, entity_pos_t range, entity_pos_t yOrigin, entity_pos_t angle) const { entity_pos_t r = entity_pos_t::Zero(); CFixedVector3D pos(pos1); - pos.Y += elevationBonus; + pos.Y += yOrigin; entity_pos_t orientation = rot.Y; entity_pos_t maxAngle = orientation + angle/2; entity_pos_t minAngle = orientation - angle/2; int numberOfSteps = 16; if (angle == entity_pos_t::Zero()) numberOfSteps = 1; std::vector coords = getParabolicRangeForm(pos, range, range*2, minAngle, maxAngle, numberOfSteps); entity_pos_t part = entity_pos_t::FromInt(numberOfSteps); for (int i = 0; i < numberOfSteps; ++i) r = r + CFixedVector2D(coords[2*i],coords[2*i+1]).Length() / part; return r; } virtual std::vector getParabolicRangeForm(CFixedVector3D pos, entity_pos_t maxRange, entity_pos_t cutoff, entity_pos_t minAngle, entity_pos_t maxAngle, int numberOfSteps) const { std::vector r; CmpPtr cmpTerrain(GetSystemEntity()); if (!cmpTerrain) return r; // angle = 0 goes in the positive Z direction u64 precisionSquared = SQUARE_U64_FIXED(PARABOLIC_RANGE_TOLERANCE); CmpPtr cmpWaterManager(GetSystemEntity()); entity_pos_t waterLevel = cmpWaterManager ? cmpWaterManager->GetWaterLevel(pos.X, pos.Z) : entity_pos_t::Zero(); entity_pos_t thisHeight = pos.Y > waterLevel ? pos.Y : waterLevel; for (int i = 0; i < numberOfSteps; ++i) { entity_pos_t angle = minAngle + (maxAngle - minAngle) / numberOfSteps * i; entity_pos_t sin; entity_pos_t cos; entity_pos_t minDistance = entity_pos_t::Zero(); entity_pos_t maxDistance = cutoff; sincos_approx(angle, sin, cos); CFixedVector2D minVector = CFixedVector2D(entity_pos_t::Zero(), entity_pos_t::Zero()); CFixedVector2D maxVector = CFixedVector2D(sin, cos).Multiply(cutoff); entity_pos_t targetHeight = cmpTerrain->GetGroundLevel(pos.X+maxVector.X, pos.Z+maxVector.Y); // use water level to display range on water targetHeight = targetHeight > waterLevel ? targetHeight : waterLevel; if (InParabolicRange(CFixedVector3D(maxVector.X, targetHeight-thisHeight, maxVector.Y), maxRange)) { r.push_back(maxVector.X); r.push_back(maxVector.Y); continue; } // Loop until vectors come close enough while ((maxVector - minVector).CompareLengthSquared(precisionSquared) > 0) { // difference still bigger than precision, bisect to get smaller difference entity_pos_t newDistance = (minDistance+maxDistance)/entity_pos_t::FromInt(2); CFixedVector2D newVector = CFixedVector2D(sin, cos).Multiply(newDistance); // get the height of the ground targetHeight = cmpTerrain->GetGroundLevel(pos.X+newVector.X, pos.Z+newVector.Y); targetHeight = targetHeight > waterLevel ? targetHeight : waterLevel; if (InParabolicRange(CFixedVector3D(newVector.X, targetHeight-thisHeight, newVector.Y), maxRange)) { // new vector is in parabolic range, so this is a new minVector minVector = newVector; minDistance = newDistance; } else { // new vector is out parabolic range, so this is a new maxVector maxVector = newVector; maxDistance = newDistance; } } r.push_back(maxVector.X); r.push_back(maxVector.Y); } r.push_back(r[0]); r.push_back(r[1]); return r; } Query ConstructQuery(entity_id_t source, entity_pos_t minRange, entity_pos_t maxRange, const std::vector& owners, int requiredInterface, u8 flagsMask, bool accountForSize) const { - // Min range must be non-negative + // Min range must be non-negative. if (minRange < entity_pos_t::Zero()) LOGWARNING("CCmpRangeManager: Invalid min range %f in query for entity %u", minRange.ToDouble(), source); - // Max range must be non-negative, or else -1 - if (maxRange < entity_pos_t::Zero() && maxRange != entity_pos_t::FromInt(-1)) + // Max range must be non-negative, or else ALWAYS_IN_RANGE. + // TODO add NEVER_IN_RANGE. + if (maxRange < entity_pos_t::Zero() && maxRange != ALWAYS_IN_RANGE) LOGWARNING("CCmpRangeManager: Invalid max range %f in query for entity %u", maxRange.ToDouble(), source); Query q; q.enabled = false; q.parabolic = false; q.source = GetSimContext().GetComponentManager().LookupEntityHandle(source); q.minRange = minRange; q.maxRange = maxRange; - q.elevationBonus = entity_pos_t::Zero(); + q.yOrigin = entity_pos_t::Zero(); q.accountForSize = accountForSize; - if (q.accountForSize && q.source.GetId() != INVALID_ENTITY && q.maxRange != entity_pos_t::FromInt(-1)) + if (q.accountForSize && q.source.GetId() != INVALID_ENTITY && q.maxRange != ALWAYS_IN_RANGE) { u32 size = 0; if (ENTITY_IS_LOCAL(q.source.GetId())) { CmpPtr cmpObstruction(GetSimContext(), q.source.GetId()); if (cmpObstruction) size = cmpObstruction->GetSize().ToInt_RoundToInfinity(); } else { EntityMap::const_iterator it = m_EntityData.find(q.source.GetId()); if (it != m_EntityData.end()) size = it->second.size; } // Adjust the range query based on the querier's obstruction radius. // The smallest side of the obstruction isn't known here, so we can't safely adjust the min-range, only the max. // 'size' is the diagonal size rounded up so this will cover all possible rotations of the querier. q.maxRange += fixed::FromInt(size); } q.ownersMask = 0; for (size_t i = 0; i < owners.size(); ++i) q.ownersMask |= CalcOwnerMask(owners[i]); if (q.ownersMask == 0) LOGWARNING("CCmpRangeManager: No owners in query for entity %u", source); q.interface = requiredInterface; q.flagsMask = flagsMask; return q; } Query ConstructParabolicQuery(entity_id_t source, - entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t elevationBonus, + entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t yOrigin, const std::vector& owners, int requiredInterface, u8 flagsMask, bool accountForSize) const { Query q = ConstructQuery(source, minRange, maxRange, owners, requiredInterface, flagsMask, accountForSize); q.parabolic = true; - q.elevationBonus = elevationBonus; + q.yOrigin = yOrigin; return q; } void RenderSubmit(SceneCollector& collector) { if (!m_DebugOverlayEnabled) return; static CColor disabledRingColor(1, 0, 0, 1); // red static CColor enabledRingColor(0, 1, 0, 1); // green static CColor subdivColor(0, 0, 1, 1); // blue static CColor rayColor(1, 1, 0, 0.2f); if (m_DebugOverlayDirty) { m_DebugOverlayLines.clear(); for (std::map::iterator it = m_Queries.begin(); it != m_Queries.end(); ++it) { Query& q = it->second; CmpPtr cmpSourcePosition(q.source); if (!cmpSourcePosition || !cmpSourcePosition->IsInWorld()) continue; CFixedVector2D pos = cmpSourcePosition->GetPosition2D(); // Draw the max range circle if (!q.parabolic) { m_DebugOverlayLines.push_back(SOverlayLine()); m_DebugOverlayLines.back().m_Color = (q.enabled ? enabledRingColor : disabledRingColor); SimRender::ConstructCircleOnGround(GetSimContext(), pos.X.ToFloat(), pos.Y.ToFloat(), q.maxRange.ToFloat(), m_DebugOverlayLines.back(), true); } else { - // elevation bonus is part of the 3D position. As if the unit is really that much higher + // yOrigin is part of the 3D position. As if the unit is really that much higher. CFixedVector3D pos3D = cmpSourcePosition->GetPosition(); - pos3D.Y += q.elevationBonus; + pos3D.Y += q.yOrigin; std::vector coords; // Get the outline from cache if possible if (ParabolicRangesOutlines.find(q.source.GetId()) != ParabolicRangesOutlines.end()) { EntityParabolicRangeOutline e = ParabolicRangesOutlines[q.source.GetId()]; if (e.position == pos3D && e.range == q.maxRange) { // outline is cached correctly, use it coords = e.outline; } else { // outline was cached, but important parameters changed // (position, elevation, range) // update it coords = getParabolicRangeForm(pos3D,q.maxRange,q.maxRange*2, entity_pos_t::Zero(), entity_pos_t::FromFloat(2.0f*3.14f),70); e.outline = coords; e.range = q.maxRange; e.position = pos3D; ParabolicRangesOutlines[q.source.GetId()] = e; } } else { // outline wasn't cached (first time you enable the range overlay // or you created a new entiy) // cache a new outline coords = getParabolicRangeForm(pos3D,q.maxRange,q.maxRange*2, entity_pos_t::Zero(), entity_pos_t::FromFloat(2.0f*3.14f),70); EntityParabolicRangeOutline e; e.source = q.source.GetId(); e.range = q.maxRange; e.position = pos3D; e.outline = coords; ParabolicRangesOutlines[q.source.GetId()] = e; } CColor thiscolor = q.enabled ? enabledRingColor : disabledRingColor; // draw the outline (piece by piece) for (size_t i = 3; i < coords.size(); i += 2) { std::vector c; c.push_back((coords[i - 3] + pos3D.X).ToFloat()); c.push_back((coords[i - 2] + pos3D.Z).ToFloat()); c.push_back((coords[i - 1] + pos3D.X).ToFloat()); c.push_back((coords[i] + pos3D.Z).ToFloat()); m_DebugOverlayLines.push_back(SOverlayLine()); m_DebugOverlayLines.back().m_Color = thiscolor; SimRender::ConstructLineOnGround(GetSimContext(), c, m_DebugOverlayLines.back(), true); } } // Draw the min range circle if (!q.minRange.IsZero()) SimRender::ConstructCircleOnGround(GetSimContext(), pos.X.ToFloat(), pos.Y.ToFloat(), q.minRange.ToFloat(), m_DebugOverlayLines.back(), true); // Draw a ray from the source to each matched entity for (size_t i = 0; i < q.lastMatch.size(); ++i) { CmpPtr cmpTargetPosition(GetSimContext(), q.lastMatch[i]); if (!cmpTargetPosition || !cmpTargetPosition->IsInWorld()) continue; CFixedVector2D targetPos = cmpTargetPosition->GetPosition2D(); std::vector coords; coords.push_back(pos.X.ToFloat()); coords.push_back(pos.Y.ToFloat()); coords.push_back(targetPos.X.ToFloat()); coords.push_back(targetPos.Y.ToFloat()); m_DebugOverlayLines.push_back(SOverlayLine()); m_DebugOverlayLines.back().m_Color = rayColor; SimRender::ConstructLineOnGround(GetSimContext(), coords, m_DebugOverlayLines.back(), true); } } // render subdivision grid float divSize = m_Subdivision.GetDivisionSize(); int size = m_Subdivision.GetWidth(); for (int x = 0; x < size; ++x) { for (int y = 0; y < size; ++y) { m_DebugOverlayLines.push_back(SOverlayLine()); m_DebugOverlayLines.back().m_Color = subdivColor; float xpos = x*divSize + divSize/2; float zpos = y*divSize + divSize/2; SimRender::ConstructSquareOnGround(GetSimContext(), xpos, zpos, divSize, divSize, 0.0f, m_DebugOverlayLines.back(), false, 1.0f); } } m_DebugOverlayDirty = false; } for (size_t i = 0; i < m_DebugOverlayLines.size(); ++i) collector.Submit(&m_DebugOverlayLines[i]); } virtual u8 GetEntityFlagMask(const std::string& identifier) const { if (identifier == "normal") return FlagMasks::Normal; if (identifier == "injured") return FlagMasks::Injured; LOGWARNING("CCmpRangeManager: Invalid flag identifier %s", identifier.c_str()); return FlagMasks::None; } virtual void SetEntityFlag(entity_id_t ent, const std::string& identifier, bool value) { EntityMap::iterator it = m_EntityData.find(ent); // We don't have this entity if (it == m_EntityData.end()) return; u8 flag = GetEntityFlagMask(identifier); if (flag == FlagMasks::None) LOGWARNING("CCmpRangeManager: Invalid flag identifier %s for entity %u", identifier.c_str(), ent); else it->second.SetFlag(flag, value); } // **************************************************************** // LOS implementation: virtual CLosQuerier GetLosQuerier(player_id_t player) const { if (GetLosRevealAll(player)) return CLosQuerier(0xFFFFFFFFu, m_LosStateRevealed, m_LosVerticesPerSide); else return CLosQuerier(GetSharedLosMask(player), m_LosState, m_LosVerticesPerSide); } virtual void ActivateScriptedVisibility(entity_id_t ent, bool status) { EntityMap::iterator it = m_EntityData.find(ent); if (it != m_EntityData.end()) it->second.SetFlag(status); } LosVisibility ComputeLosVisibility(CEntityHandle ent, player_id_t player) const { // Entities not with positions in the world are never visible if (ent.GetId() == INVALID_ENTITY) return LosVisibility::HIDDEN; CmpPtr cmpPosition(ent); if (!cmpPosition || !cmpPosition->IsInWorld()) return LosVisibility::HIDDEN; // Mirage entities, whatever the situation, are visible for one specific player CmpPtr cmpMirage(ent); if (cmpMirage && cmpMirage->GetPlayer() != player) return LosVisibility::HIDDEN; CFixedVector2D pos = cmpPosition->GetPosition2D(); int i = (pos.X / LOS_TILE_SIZE).ToInt_RoundToNearest(); int j = (pos.Y / LOS_TILE_SIZE).ToInt_RoundToNearest(); // Reveal flag makes all positioned entities visible and all mirages useless if (GetLosRevealAll(player)) { if (LosIsOffWorld(i, j) || cmpMirage) return LosVisibility::HIDDEN; return LosVisibility::VISIBLE; } // Get visible regions CLosQuerier los(GetSharedLosMask(player), m_LosState, m_LosVerticesPerSide); CmpPtr cmpVisibility(ent); // Possibly ask the scripted Visibility component EntityMap::const_iterator it = m_EntityData.find(ent.GetId()); if (it != m_EntityData.end()) { if (it->second.HasFlag() && cmpVisibility) return cmpVisibility->GetVisibility(player, los.IsVisible(i, j), los.IsExplored(i, j)); } else { if (cmpVisibility && cmpVisibility->IsActivated()) return cmpVisibility->GetVisibility(player, los.IsVisible(i, j), los.IsExplored(i, j)); } // Else, default behavior if (los.IsVisible(i, j)) { if (cmpMirage) return LosVisibility::HIDDEN; return LosVisibility::VISIBLE; } if (!los.IsExplored(i, j)) return LosVisibility::HIDDEN; // Invisible if the 'retain in fog' flag is not set, and in a non-visible explored region // Try using the 'retainInFog' flag in m_EntityData to save a script call if (it != m_EntityData.end()) { if (!it->second.HasFlag()) return LosVisibility::HIDDEN; } else { if (!(cmpVisibility && cmpVisibility->GetRetainInFog())) return LosVisibility::HIDDEN; } if (cmpMirage) return LosVisibility::FOGGED; CmpPtr cmpOwnership(ent); if (!cmpOwnership) return LosVisibility::FOGGED; if (cmpOwnership->GetOwner() == player) { CmpPtr cmpFogging(ent); if (!(cmpFogging && cmpFogging->IsMiraged(player))) return LosVisibility::FOGGED; return LosVisibility::HIDDEN; } // Fogged entities are hidden in two cases: // - They were not scouted // - A mirage replaces them CmpPtr cmpFogging(ent); if (cmpFogging && cmpFogging->IsActivated() && (!cmpFogging->WasSeen(player) || cmpFogging->IsMiraged(player))) return LosVisibility::HIDDEN; return LosVisibility::FOGGED; } LosVisibility ComputeLosVisibility(entity_id_t ent, player_id_t player) const { CEntityHandle handle = GetSimContext().GetComponentManager().LookupEntityHandle(ent); return ComputeLosVisibility(handle, player); } virtual LosVisibility GetLosVisibility(CEntityHandle ent, player_id_t player) const { entity_id_t entId = ent.GetId(); // Entities not with positions in the world are never visible if (entId == INVALID_ENTITY) return LosVisibility::HIDDEN; CmpPtr cmpPosition(ent); if (!cmpPosition || !cmpPosition->IsInWorld()) return LosVisibility::HIDDEN; // Gaia and observers do not have a visibility cache if (player <= 0) return ComputeLosVisibility(ent, player); CFixedVector2D pos = cmpPosition->GetPosition2D(); if (IsVisibilityDirty(m_DirtyVisibility[PosToLosRegionsHelper(pos.X, pos.Y)], player)) return ComputeLosVisibility(ent, player); if (std::find(m_ModifiedEntities.begin(), m_ModifiedEntities.end(), entId) != m_ModifiedEntities.end()) return ComputeLosVisibility(ent, player); EntityMap::const_iterator it = m_EntityData.find(entId); if (it == m_EntityData.end()) return ComputeLosVisibility(ent, player); return static_cast(GetPlayerVisibility(it->second.visibilities, player)); } virtual LosVisibility GetLosVisibility(entity_id_t ent, player_id_t player) const { CEntityHandle handle = GetSimContext().GetComponentManager().LookupEntityHandle(ent); return GetLosVisibility(handle, player); } virtual LosVisibility GetLosVisibilityPosition(entity_pos_t x, entity_pos_t z, player_id_t player) const { int i = (x / LOS_TILE_SIZE).ToInt_RoundToNearest(); int j = (z / LOS_TILE_SIZE).ToInt_RoundToNearest(); // Reveal flag makes all positioned entities visible and all mirages useless if (GetLosRevealAll(player)) { if (LosIsOffWorld(i, j)) return LosVisibility::HIDDEN; else return LosVisibility::VISIBLE; } // Get visible regions CLosQuerier los(GetSharedLosMask(player), m_LosState, m_LosVerticesPerSide); if (los.IsVisible(i,j)) return LosVisibility::VISIBLE; if (los.IsExplored(i,j)) return LosVisibility::FOGGED; return LosVisibility::HIDDEN; } size_t GetVerticesPerSide() const { return m_LosVerticesPerSide; } LosRegion LosVertexToLosRegionsHelper(u16 x, u16 z) const { return LosRegion { Clamp(x/LOS_REGION_RATIO, 0, m_LosRegionsPerSide - 1), Clamp(z/LOS_REGION_RATIO, 0, m_LosRegionsPerSide - 1) }; } LosRegion PosToLosRegionsHelper(entity_pos_t x, entity_pos_t z) const { u16 i = Clamp( (x/(LOS_TILE_SIZE*LOS_REGION_RATIO)).ToInt_RoundToZero(), 0, m_LosRegionsPerSide - 1); u16 j = Clamp( (z/(LOS_TILE_SIZE*LOS_REGION_RATIO)).ToInt_RoundToZero(), 0, m_LosRegionsPerSide - 1); return std::make_pair(i, j); } void AddToRegion(LosRegion region, entity_id_t ent) { m_LosRegions[region].insert(ent); } void RemoveFromRegion(LosRegion region, entity_id_t ent) { std::set::const_iterator regionIt = m_LosRegions[region].find(ent); if (regionIt != m_LosRegions[region].end()) m_LosRegions[region].erase(regionIt); } void UpdateVisibilityData() { PROFILE("UpdateVisibilityData"); for (u16 i = 0; i < m_LosRegionsPerSide; ++i) for (u16 j = 0; j < m_LosRegionsPerSide; ++j) { LosRegion pos{i, j}; for (player_id_t player = 1; player < MAX_LOS_PLAYER_ID + 1; ++player) if (IsVisibilityDirty(m_DirtyVisibility[pos], player) || m_GlobalPlayerVisibilityUpdate[player-1] == 1 || m_GlobalVisibilityUpdate) for (const entity_id_t& ent : m_LosRegions[pos]) UpdateVisibility(ent, player); m_DirtyVisibility[pos] = 0; } std::fill(m_GlobalPlayerVisibilityUpdate.begin(), m_GlobalPlayerVisibilityUpdate.end(), false); m_GlobalVisibilityUpdate = false; // Calling UpdateVisibility can modify m_ModifiedEntities, so be careful: // infinite loops could be triggered by feedback between entities and their mirages. std::map attempts; while (!m_ModifiedEntities.empty()) { entity_id_t ent = m_ModifiedEntities.back(); m_ModifiedEntities.pop_back(); ++attempts[ent]; ENSURE(attempts[ent] < 100 && "Infinite loop in UpdateVisibilityData"); UpdateVisibility(ent); } } virtual void RequestVisibilityUpdate(entity_id_t ent) { if (std::find(m_ModifiedEntities.begin(), m_ModifiedEntities.end(), ent) == m_ModifiedEntities.end()) m_ModifiedEntities.push_back(ent); } void UpdateVisibility(entity_id_t ent, player_id_t player) { EntityMap::iterator itEnts = m_EntityData.find(ent); if (itEnts == m_EntityData.end()) return; LosVisibility oldVis = GetPlayerVisibility(itEnts->second.visibilities, player); LosVisibility newVis = ComputeLosVisibility(itEnts->first, player); if (oldVis == newVis) return; itEnts->second.visibilities = (itEnts->second.visibilities & ~(0x3 << 2 * (player - 1))) | ((u8)newVis << 2 * (player - 1)); CMessageVisibilityChanged msg(player, ent, static_cast(oldVis), static_cast(newVis)); GetSimContext().GetComponentManager().PostMessage(ent, msg); } void UpdateVisibility(entity_id_t ent) { for (player_id_t player = 1; player < MAX_LOS_PLAYER_ID + 1; ++player) UpdateVisibility(ent, player); } virtual void SetLosRevealAll(player_id_t player, bool enabled) { if (player == -1) m_LosRevealAll[MAX_LOS_PLAYER_ID+1] = enabled; else { ENSURE(player >= 0 && player <= MAX_LOS_PLAYER_ID); m_LosRevealAll[player] = enabled; } // On next update, update the visibility of every entity in the world m_GlobalVisibilityUpdate = true; } virtual bool GetLosRevealAll(player_id_t player) const { // Special player value can force reveal-all for every player if (m_LosRevealAll[MAX_LOS_PLAYER_ID+1] || player == -1) return true; ENSURE(player >= 0 && player <= MAX_LOS_PLAYER_ID+1); // Otherwise check the player-specific flag if (m_LosRevealAll[player]) return true; return false; } virtual void SetLosCircular(bool enabled) { m_LosCircular = enabled; ResetDerivedData(); } virtual bool GetLosCircular() const { return m_LosCircular; } virtual void SetSharedLos(player_id_t player, const std::vector& players) { m_SharedLosMasks[player] = CalcSharedLosMask(players); // Units belonging to any of 'players' can now trigger visibility updates for 'player'. // If shared LOS partners have been removed, we disable visibility updates from them // in order to improve performance. That also allows us to properly determine whether // 'player' needs a global visibility update for this turn. bool modified = false; for (player_id_t p = 1; p < MAX_LOS_PLAYER_ID+1; ++p) { bool inList = std::find(players.begin(), players.end(), p) != players.end(); if (SetPlayerSharedDirtyVisibilityBit(m_SharedDirtyVisibilityMasks[p], player, inList)) modified = true; } if (modified && (size_t)player <= m_GlobalPlayerVisibilityUpdate.size()) m_GlobalPlayerVisibilityUpdate[player-1] = 1; } virtual u32 GetSharedLosMask(player_id_t player) const { return m_SharedLosMasks[player]; } void ExploreMap(player_id_t p) { for (i32 j = 0; j < m_LosVerticesPerSide; ++j) for (i32 i = 0; i < m_LosVerticesPerSide; ++i) { if (LosIsOffWorld(i,j)) continue; u32 &explored = m_ExploredVertices.at(p); explored += !(m_LosState.get(i, j) & ((u32)LosState::EXPLORED << (2*(p-1)))); m_LosState.get(i, j) |= ((u32)LosState::EXPLORED << (2*(p-1))); } SeeExploredEntities(p); } virtual void ExploreTerritories() { PROFILE3("ExploreTerritories"); CmpPtr cmpTerritoryManager(GetSystemEntity()); const Grid& grid = cmpTerritoryManager->GetTerritoryGrid(); // Territory data is stored per territory-tile (typically a multiple of terrain-tiles). // LOS data is stored per los vertex (in reality tiles too, but it's the center that matters). // This scales from LOS coordinates to Territory coordinates. auto scale = [](i32 coord, i32 max) -> i32 { return std::min(max, (coord * LOS_TILE_SIZE + LOS_TILE_SIZE / 2) / (ICmpTerritoryManager::NAVCELLS_PER_TERRITORY_TILE * Pathfinding::NAVCELL_SIZE_INT)); }; // For each territory-tile, if it is owned by a valid player then update the LOS // for every vertex inside/around that tile, to mark them as explored. for (i32 j = 0; j < m_LosVerticesPerSide; ++j) for (i32 i = 0; i < m_LosVerticesPerSide; ++i) { // TODO: This fetches data redundantly if the los grid is smaller than the territory grid // (but it's unlikely to matter much). u8 p = grid.get(scale(i, grid.width() - 1), scale(j, grid.height() - 1)) & ICmpTerritoryManager::TERRITORY_PLAYER_MASK; if (p > 0 && p <= MAX_LOS_PLAYER_ID) { u32& explored = m_ExploredVertices.at(p); if (LosIsOffWorld(i, j)) continue; u32& losState = m_LosState.get(i, j); if (!(losState & ((u32)LosState::EXPLORED << (2*(p-1))))) { ++explored; losState |= ((u32)LosState::EXPLORED << (2*(p-1))); } } } for (player_id_t p = 1; p < MAX_LOS_PLAYER_ID+1; ++p) SeeExploredEntities(p); } /** * Force any entity in explored territory to appear for player p. * This is useful for miraging entities inside the territory borders at the beginning of a game, * or if the "Explore Map" option has been set. */ void SeeExploredEntities(player_id_t p) const { // Warning: Code related to fogging (like ForceMiraging) shouldn't be // invoked while iterating through m_EntityData. // Otherwise, by deleting mirage entities and so on, that code will // change the indexes in the map, leading to segfaults. // So we just remember what entities to mirage and do that later. std::vector miragableEntities; for (EntityMap::const_iterator it = m_EntityData.begin(); it != m_EntityData.end(); ++it) { CmpPtr cmpPosition(GetSimContext(), it->first); if (!cmpPosition || !cmpPosition->IsInWorld()) continue; CFixedVector2D pos = cmpPosition->GetPosition2D(); int i = (pos.X / LOS_TILE_SIZE).ToInt_RoundToNearest(); int j = (pos.Y / LOS_TILE_SIZE).ToInt_RoundToNearest(); CLosQuerier los(GetSharedLosMask(p), m_LosState, m_LosVerticesPerSide); if (!los.IsExplored(i,j) || los.IsVisible(i,j)) continue; CmpPtr cmpFogging(GetSimContext(), it->first); if (cmpFogging) miragableEntities.push_back(it->first); } for (std::vector::iterator it = miragableEntities.begin(); it != miragableEntities.end(); ++it) { CmpPtr cmpFogging(GetSimContext(), *it); ENSURE(cmpFogging && "Impossible to retrieve Fogging component, previously achieved"); cmpFogging->ForceMiraging(p); } } virtual void RevealShore(player_id_t p, bool enable) { if (p <= 0 || p > MAX_LOS_PLAYER_ID) return; // Maximum distance to the shore const u16 maxdist = 10; CmpPtr cmpPathfinder(GetSystemEntity()); const Grid& shoreGrid = cmpPathfinder->ComputeShoreGrid(true); ENSURE(shoreGrid.m_W == m_LosVerticesPerSide-1 && shoreGrid.m_H == m_LosVerticesPerSide-1); Grid& counts = m_LosPlayerCounts.at(p); ENSURE(!counts.blank()); for (u16 j = 0; j < shoreGrid.m_H; ++j) for (u16 i = 0; i < shoreGrid.m_W; ++i) { u16 shoredist = shoreGrid.get(i, j); if (shoredist > maxdist) continue; // Maybe we could be more clever and don't add dummy strips of one tile if (enable) LosAddStripHelper(p, i, i, j, counts); else LosRemoveStripHelper(p, i, i, j, counts); } } /** * Returns whether the given vertex is outside the normal bounds of the world * (i.e. outside the range of a circular map) */ inline bool LosIsOffWorld(ssize_t i, ssize_t j) const { if (m_LosCircular) { // With a circular map, vertex is off-world if hypot(i - size/2, j - size/2) >= size/2: ssize_t dist2 = (i - m_LosVerticesPerSide/2)*(i - m_LosVerticesPerSide/2) + (j - m_LosVerticesPerSide/2)*(j - m_LosVerticesPerSide/2); ssize_t r = m_LosVerticesPerSide / 2 - MAP_EDGE_TILES + 1; // subtract a bit from the radius to ensure nice // SoD blurring around the edges of the map return (dist2 >= r*r); } else { // With a square map, the outermost edge of the map should be off-world, // so the SoD texture blends out nicely return i < MAP_EDGE_TILES || j < MAP_EDGE_TILES || i >= m_LosVerticesPerSide - MAP_EDGE_TILES || j >= m_LosVerticesPerSide - MAP_EDGE_TILES; } } /** * Update the LOS state of tiles within a given horizontal strip (i0,j) to (i1,j) (inclusive). */ inline void LosAddStripHelper(u8 owner, i32 i0, i32 i1, i32 j, Grid& counts) { if (i1 < i0) return; u32 &explored = m_ExploredVertices.at(owner); for (i32 i = i0; i <= i1; ++i) { // Increasing from zero to non-zero - move from unexplored/explored to visible+explored if (counts.get(i, j) == 0) { if (!LosIsOffWorld(i, j)) { explored += !(m_LosState.get(i, j) & ((u32)LosState::EXPLORED << (2*(owner-1)))); m_LosState.get(i, j) |= (((int)LosState::VISIBLE | (u32)LosState::EXPLORED) << (2*(owner-1))); } MarkVisibilityDirtyAroundTile(owner, i, j); } ENSURE(counts.get(i, j) < std::numeric_limits::max()); counts.get(i, j) = (u16)(counts.get(i, j) + 1); // ignore overflow; the player should never have 64K units } } /** * Update the LOS state of tiles within a given horizontal strip (i0,j) to (i1,j) (inclusive). */ inline void LosRemoveStripHelper(u8 owner, i32 i0, i32 i1, i32 j, Grid& counts) { if (i1 < i0) return; for (i32 i = i0; i <= i1; ++i) { ASSERT(counts.get(i, j) > 0); counts.get(i, j) = (u16)(counts.get(i, j) - 1); // Decreasing from non-zero to zero - move from visible+explored to explored if (counts.get(i, j) == 0) { // (If LosIsOffWorld then this is a no-op, so don't bother doing the check) m_LosState.get(i, j) &= ~((int)LosState::VISIBLE << (2*(owner-1))); MarkVisibilityDirtyAroundTile(owner, i, j); } } } inline void MarkVisibilityDirtyAroundTile(u8 owner, i32 i, i32 j) { // If we're still in the deserializing process, we must not modify m_DirtyVisibility if (m_Deserializing) return; // Mark the LoS regions around the updated vertex // 1: left-up, 2: right-up, 3: left-down, 4: right-down LosRegion n1 = LosVertexToLosRegionsHelper(i-1, j-1); LosRegion n2 = LosVertexToLosRegionsHelper(i-1, j); LosRegion n3 = LosVertexToLosRegionsHelper(i, j-1); LosRegion n4 = LosVertexToLosRegionsHelper(i, j); u16 sharedDirtyVisibilityMask = m_SharedDirtyVisibilityMasks[owner]; if (j > 0 && i > 0) m_DirtyVisibility[n1] |= sharedDirtyVisibilityMask; if (n2 != n1 && j > 0 && i < m_LosVerticesPerSide) m_DirtyVisibility[n2] |= sharedDirtyVisibilityMask; if (n3 != n1 && j < m_LosVerticesPerSide && i > 0) m_DirtyVisibility[n3] |= sharedDirtyVisibilityMask; if (n4 != n1 && j < m_LosVerticesPerSide && i < m_LosVerticesPerSide) m_DirtyVisibility[n4] |= sharedDirtyVisibilityMask; } /** * Update the LOS state of tiles within a given circular range, * either adding or removing visibility depending on the template parameter. * Assumes owner is in the valid range. */ template void LosUpdateHelper(u8 owner, entity_pos_t visionRange, CFixedVector2D pos) { if (m_LosVerticesPerSide == 0) // do nothing if not initialised yet return; PROFILE("LosUpdateHelper"); Grid& counts = m_LosPlayerCounts.at(owner); // Lazy initialisation of counts: if (counts.blank()) counts.resize(m_LosVerticesPerSide, m_LosVerticesPerSide); // Compute the circular region as a series of strips. // Rather than quantise pos to vertexes, we do more precise sub-tile computations // to get smoother behaviour as a unit moves rather than jumping a whole tile // at once. // To avoid the cost of sqrt when computing the outline of the circle, // we loop from the bottom to the top and estimate the width of the current // strip based on the previous strip, then adjust each end of the strip // inwards or outwards until it's the widest that still falls within the circle. // Compute top/bottom coordinates, and clamp to exclude the 1-tile border around the map // (so that we never render the sharp edge of the map) i32 j0 = ((pos.Y - visionRange)/LOS_TILE_SIZE).ToInt_RoundToInfinity(); i32 j1 = ((pos.Y + visionRange)/LOS_TILE_SIZE).ToInt_RoundToNegInfinity(); i32 j0clamp = std::max(j0, 1); i32 j1clamp = std::min(j1, m_LosVerticesPerSide-2); // Translate world coordinates into fractional tile-space coordinates entity_pos_t x = pos.X / LOS_TILE_SIZE; entity_pos_t y = pos.Y / LOS_TILE_SIZE; entity_pos_t r = visionRange / LOS_TILE_SIZE; entity_pos_t r2 = r.Square(); // Compute the integers on either side of x i32 xfloor = (x - entity_pos_t::Epsilon()).ToInt_RoundToNegInfinity(); i32 xceil = (x + entity_pos_t::Epsilon()).ToInt_RoundToInfinity(); // Initialise the strip (i0, i1) to a rough guess i32 i0 = xfloor; i32 i1 = xceil; for (i32 j = j0clamp; j <= j1clamp; ++j) { // Adjust i0 and i1 to be the outermost values that don't exceed // the circle's radius (i.e. require dy^2 + dx^2 <= r^2). // When moving the points inwards, clamp them to xceil+1 or xfloor-1 // so they don't accidentally shoot off in the wrong direction forever. entity_pos_t dy = entity_pos_t::FromInt(j) - y; entity_pos_t dy2 = dy.Square(); while (dy2 + (entity_pos_t::FromInt(i0-1) - x).Square() <= r2) --i0; while (i0 < xceil && dy2 + (entity_pos_t::FromInt(i0) - x).Square() > r2) ++i0; while (dy2 + (entity_pos_t::FromInt(i1+1) - x).Square() <= r2) ++i1; while (i1 > xfloor && dy2 + (entity_pos_t::FromInt(i1) - x).Square() > r2) --i1; #if DEBUG_RANGE_MANAGER_BOUNDS if (i0 <= i1) { ENSURE(dy2 + (entity_pos_t::FromInt(i0) - x).Square() <= r2); ENSURE(dy2 + (entity_pos_t::FromInt(i1) - x).Square() <= r2); } ENSURE(dy2 + (entity_pos_t::FromInt(i0 - 1) - x).Square() > r2); ENSURE(dy2 + (entity_pos_t::FromInt(i1 + 1) - x).Square() > r2); #endif // Clamp the strip to exclude the 1-tile border, // then add or remove the strip as requested i32 i0clamp = std::max(i0, 1); i32 i1clamp = std::min(i1, m_LosVerticesPerSide-2); if (adding) LosAddStripHelper(owner, i0clamp, i1clamp, j, counts); else LosRemoveStripHelper(owner, i0clamp, i1clamp, j, counts); } } /** * Update the LOS state of tiles within a given circular range, * by removing visibility around the 'from' position * and then adding visibility around the 'to' position. */ void LosUpdateHelperIncremental(u8 owner, entity_pos_t visionRange, CFixedVector2D from, CFixedVector2D to) { if (m_LosVerticesPerSide == 0) // do nothing if not initialised yet return; PROFILE("LosUpdateHelperIncremental"); Grid& counts = m_LosPlayerCounts.at(owner); // Lazy initialisation of counts: if (counts.blank()) counts.resize(m_LosVerticesPerSide, m_LosVerticesPerSide); // See comments in LosUpdateHelper. // This does exactly the same, except computing the strips for // both circles simultaneously. // (The idea is that the circles will be heavily overlapping, // so we can compute the difference between the removed/added strips // and only have to touch tiles that have a net change.) i32 j0_from = ((from.Y - visionRange)/LOS_TILE_SIZE).ToInt_RoundToInfinity(); i32 j1_from = ((from.Y + visionRange)/LOS_TILE_SIZE).ToInt_RoundToNegInfinity(); i32 j0_to = ((to.Y - visionRange)/LOS_TILE_SIZE).ToInt_RoundToInfinity(); i32 j1_to = ((to.Y + visionRange)/LOS_TILE_SIZE).ToInt_RoundToNegInfinity(); i32 j0clamp = std::max(std::min(j0_from, j0_to), 1); i32 j1clamp = std::min(std::max(j1_from, j1_to), m_LosVerticesPerSide-2); entity_pos_t x_from = from.X / LOS_TILE_SIZE; entity_pos_t y_from = from.Y / LOS_TILE_SIZE; entity_pos_t x_to = to.X / LOS_TILE_SIZE; entity_pos_t y_to = to.Y / LOS_TILE_SIZE; entity_pos_t r = visionRange / LOS_TILE_SIZE; entity_pos_t r2 = r.Square(); i32 xfloor_from = (x_from - entity_pos_t::Epsilon()).ToInt_RoundToNegInfinity(); i32 xceil_from = (x_from + entity_pos_t::Epsilon()).ToInt_RoundToInfinity(); i32 xfloor_to = (x_to - entity_pos_t::Epsilon()).ToInt_RoundToNegInfinity(); i32 xceil_to = (x_to + entity_pos_t::Epsilon()).ToInt_RoundToInfinity(); i32 i0_from = xfloor_from; i32 i1_from = xceil_from; i32 i0_to = xfloor_to; i32 i1_to = xceil_to; for (i32 j = j0clamp; j <= j1clamp; ++j) { entity_pos_t dy_from = entity_pos_t::FromInt(j) - y_from; entity_pos_t dy2_from = dy_from.Square(); while (dy2_from + (entity_pos_t::FromInt(i0_from-1) - x_from).Square() <= r2) --i0_from; while (i0_from < xceil_from && dy2_from + (entity_pos_t::FromInt(i0_from) - x_from).Square() > r2) ++i0_from; while (dy2_from + (entity_pos_t::FromInt(i1_from+1) - x_from).Square() <= r2) ++i1_from; while (i1_from > xfloor_from && dy2_from + (entity_pos_t::FromInt(i1_from) - x_from).Square() > r2) --i1_from; entity_pos_t dy_to = entity_pos_t::FromInt(j) - y_to; entity_pos_t dy2_to = dy_to.Square(); while (dy2_to + (entity_pos_t::FromInt(i0_to-1) - x_to).Square() <= r2) --i0_to; while (i0_to < xceil_to && dy2_to + (entity_pos_t::FromInt(i0_to) - x_to).Square() > r2) ++i0_to; while (dy2_to + (entity_pos_t::FromInt(i1_to+1) - x_to).Square() <= r2) ++i1_to; while (i1_to > xfloor_to && dy2_to + (entity_pos_t::FromInt(i1_to) - x_to).Square() > r2) --i1_to; #if DEBUG_RANGE_MANAGER_BOUNDS if (i0_from <= i1_from) { ENSURE(dy2_from + (entity_pos_t::FromInt(i0_from) - x_from).Square() <= r2); ENSURE(dy2_from + (entity_pos_t::FromInt(i1_from) - x_from).Square() <= r2); } ENSURE(dy2_from + (entity_pos_t::FromInt(i0_from - 1) - x_from).Square() > r2); ENSURE(dy2_from + (entity_pos_t::FromInt(i1_from + 1) - x_from).Square() > r2); if (i0_to <= i1_to) { ENSURE(dy2_to + (entity_pos_t::FromInt(i0_to) - x_to).Square() <= r2); ENSURE(dy2_to + (entity_pos_t::FromInt(i1_to) - x_to).Square() <= r2); } ENSURE(dy2_to + (entity_pos_t::FromInt(i0_to - 1) - x_to).Square() > r2); ENSURE(dy2_to + (entity_pos_t::FromInt(i1_to + 1) - x_to).Square() > r2); #endif // Check whether this strip moved at all if (!(i0_to == i0_from && i1_to == i1_from)) { i32 i0clamp_from = std::max(i0_from, 1); i32 i1clamp_from = std::min(i1_from, m_LosVerticesPerSide-2); i32 i0clamp_to = std::max(i0_to, 1); i32 i1clamp_to = std::min(i1_to, m_LosVerticesPerSide-2); // Check whether one strip is negative width, // and we can just add/remove the entire other strip if (i1clamp_from < i0clamp_from) { LosAddStripHelper(owner, i0clamp_to, i1clamp_to, j, counts); } else if (i1clamp_to < i0clamp_to) { LosRemoveStripHelper(owner, i0clamp_from, i1clamp_from, j, counts); } else { // There are four possible regions of overlap between the two strips // (remove before add, remove after add, add before remove, add after remove). // Process each of the regions as its own strip. // (If this produces negative-width strips then they'll just get ignored // which is fine.) // (If the strips don't actually overlap (which is very rare with normal unit // movement speeds), the region between them will be both added and removed, // so we have to do the add first to avoid overflowing to -1 and triggering // assertion failures.) LosAddStripHelper(owner, i0clamp_to, i0clamp_from-1, j, counts); LosAddStripHelper(owner, i1clamp_from+1, i1clamp_to, j, counts); LosRemoveStripHelper(owner, i0clamp_from, i0clamp_to-1, j, counts); LosRemoveStripHelper(owner, i1clamp_to+1, i1clamp_from, j, counts); } } } } void LosAdd(player_id_t owner, entity_pos_t visionRange, CFixedVector2D pos) { if (visionRange.IsZero() || owner <= 0 || owner > MAX_LOS_PLAYER_ID) return; LosUpdateHelper((u8)owner, visionRange, pos); } void SharingLosAdd(u16 visionSharing, entity_pos_t visionRange, CFixedVector2D pos) { if (visionRange.IsZero()) return; for (player_id_t i = 1; i < MAX_LOS_PLAYER_ID+1; ++i) if (HasVisionSharing(visionSharing, i)) LosAdd(i, visionRange, pos); } void LosRemove(player_id_t owner, entity_pos_t visionRange, CFixedVector2D pos) { if (visionRange.IsZero() || owner <= 0 || owner > MAX_LOS_PLAYER_ID) return; LosUpdateHelper((u8)owner, visionRange, pos); } void SharingLosRemove(u16 visionSharing, entity_pos_t visionRange, CFixedVector2D pos) { if (visionRange.IsZero()) return; for (player_id_t i = 1; i < MAX_LOS_PLAYER_ID+1; ++i) if (HasVisionSharing(visionSharing, i)) LosRemove(i, visionRange, pos); } void LosMove(player_id_t owner, entity_pos_t visionRange, CFixedVector2D from, CFixedVector2D to) { if (visionRange.IsZero() || owner <= 0 || owner > MAX_LOS_PLAYER_ID) return; if ((from - to).CompareLength(visionRange) > 0) { // If it's a very large move, then simply remove and add to the new position LosUpdateHelper((u8)owner, visionRange, from); LosUpdateHelper((u8)owner, visionRange, to); } else // Otherwise use the version optimised for mostly-overlapping circles LosUpdateHelperIncremental((u8)owner, visionRange, from, to); } void SharingLosMove(u16 visionSharing, entity_pos_t visionRange, CFixedVector2D from, CFixedVector2D to) { if (visionRange.IsZero()) return; for (player_id_t i = 1; i < MAX_LOS_PLAYER_ID+1; ++i) if (HasVisionSharing(visionSharing, i)) LosMove(i, visionRange, from, to); } virtual u8 GetPercentMapExplored(player_id_t player) const { return m_ExploredVertices.at((u8)player) * 100 / m_TotalInworldVertices; } virtual u8 GetUnionPercentMapExplored(const std::vector& players) const { u32 exploredVertices = 0; std::vector::const_iterator playerIt; for (i32 j = 0; j < m_LosVerticesPerSide; j++) for (i32 i = 0; i < m_LosVerticesPerSide; i++) { if (LosIsOffWorld(i, j)) continue; for (playerIt = players.begin(); playerIt != players.end(); ++playerIt) if (m_LosState.get(i, j) & ((u32)LosState::EXPLORED << (2*((*playerIt)-1)))) { exploredVertices += 1; break; } } return exploredVertices * 100 / m_TotalInworldVertices; } }; REGISTER_COMPONENT_TYPE(RangeManager) Index: ps/trunk/source/simulation2/components/ICmpObstructionManager.cpp =================================================================== --- ps/trunk/source/simulation2/components/ICmpObstructionManager.cpp (revision 26391) +++ ps/trunk/source/simulation2/components/ICmpObstructionManager.cpp (revision 26392) @@ -1,34 +1,35 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * 0 A.D. is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with 0 A.D. If not, see . */ #include "precompiled.h" #include "ICmpObstructionManager.h" #include "simulation2/system/InterfaceScripted.h" BEGIN_INTERFACE_WRAPPER(ObstructionManager) DEFINE_INTERFACE_METHOD("SetPassabilityCircular", ICmpObstructionManager, SetPassabilityCircular) DEFINE_INTERFACE_METHOD("SetDebugOverlay", ICmpObstructionManager, SetDebugOverlay) DEFINE_INTERFACE_METHOD("DistanceToPoint", ICmpObstructionManager, DistanceToPoint) DEFINE_INTERFACE_METHOD("MaxDistanceToPoint", ICmpObstructionManager, MaxDistanceToPoint) DEFINE_INTERFACE_METHOD("DistanceToTarget", ICmpObstructionManager, DistanceToTarget) DEFINE_INTERFACE_METHOD("MaxDistanceToTarget", ICmpObstructionManager, MaxDistanceToTarget) DEFINE_INTERFACE_METHOD("IsInPointRange", ICmpObstructionManager, IsInPointRange) DEFINE_INTERFACE_METHOD("IsInTargetRange", ICmpObstructionManager, IsInTargetRange) +DEFINE_INTERFACE_METHOD("IsInTargetParabolicRange", ICmpObstructionManager, IsInTargetParabolicRange) DEFINE_INTERFACE_METHOD("IsPointInPointRange", ICmpObstructionManager, IsPointInPointRange) END_INTERFACE_WRAPPER(ObstructionManager) Index: ps/trunk/source/simulation2/components/ICmpObstructionManager.h =================================================================== --- ps/trunk/source/simulation2/components/ICmpObstructionManager.h (revision 26391) +++ ps/trunk/source/simulation2/components/ICmpObstructionManager.h (revision 26392) @@ -1,585 +1,591 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * 0 A.D. is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with 0 A.D. If not, see . */ #ifndef INCLUDED_ICMPOBSTRUCTIONMANAGER #define INCLUDED_ICMPOBSTRUCTIONMANAGER #include "simulation2/system/Interface.h" #include "maths/FixedVector2D.h" #include "simulation2/helpers/Position.h" #include class IObstructionTestFilter; template class Grid; struct GridUpdateInformation; using NavcellData = u16; class PathfinderPassability; /** * Obstruction manager: provides efficient spatial queries over objects in the world. * * The class deals with two types of shape: * "static" shapes, typically representing buildings, which are rectangles with a given * width and height and angle; * and "unit" shapes, representing units that can move around the world, which have a * radius and no rotation. (Units sometimes act as axis-aligned squares, sometimes * as approximately circles, due to the algorithm used by the short pathfinder.) * * Other classes (particularly ICmpObstruction) register shapes with this interface * and keep them updated. * * The @c Test functions provide exact collision tests. * The edge of a shape counts as 'inside' the shape, for the purpose of collisions. * The functions accept an IObstructionTestFilter argument, which can restrict the * set of shapes that are counted as collisions. * * Units can be marked as either moving or stationary, which simply determines whether * certain filters include or exclude them. * * The @c Rasterize function approximates the current set of shapes onto a 2D grid, * for use with tile-based pathfinding. */ class ICmpObstructionManager : public IComponent { public: /** * Standard representation for all types of shapes, for use with geometry processing code. */ struct ObstructionSquare { entity_pos_t x, z; // position of center CFixedVector2D u, v; // 'horizontal' and 'vertical' orthogonal unit vectors, representing orientation entity_pos_t hw, hh; // half width, half height of square }; /** * External identifiers for shapes. * (This is a struct rather than a raw u32 for type-safety.) */ struct tag_t { tag_t() : n(0) {} explicit tag_t(u32 n) : n(n) {} bool valid() const { return n != 0; } u32 n; }; /** * Boolean flags affecting the obstruction behaviour of a shape. */ enum EFlags { FLAG_BLOCK_MOVEMENT = (1 << 0), // prevents units moving through this shape FLAG_BLOCK_FOUNDATION = (1 << 1), // prevents foundations being placed on this shape FLAG_BLOCK_CONSTRUCTION = (1 << 2), // prevents buildings being constructed on this shape FLAG_BLOCK_PATHFINDING = (1 << 3), // prevents the tile pathfinder choosing paths through this shape FLAG_MOVING = (1 << 4), // reserved for unitMotion - see usage there. FLAG_DELETE_UPON_CONSTRUCTION = (1 << 5) // this entity is deleted when construction of a building placed on top of this entity starts }; /** * Bitmask of EFlag values. */ typedef u8 flags_t; /** * Set the bounds of the world. * Any point outside the bounds is considered obstructed. * @param x0,z0,x1,z1 Coordinates of the corners of the world */ virtual void SetBounds(entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1) = 0; /** * Register a static shape. * * @param ent entity ID associated with this shape (or INVALID_ENTITY if none) * @param x,z coordinates of center, in world space * @param a angle of rotation (clockwise from +Z direction) * @param w width (size along X axis) * @param h height (size along Z axis) * @param flags a set of EFlags values * @param group primary control group of the shape. Must be a valid control group ID. * @param group2 Optional; secondary control group of the shape. Defaults to INVALID_ENTITY. * @return a valid tag for manipulating the shape * @see StaticShape */ virtual tag_t AddStaticShape(entity_id_t ent, entity_pos_t x, entity_pos_t z, entity_angle_t a, entity_pos_t w, entity_pos_t h, flags_t flags, entity_id_t group, entity_id_t group2 = INVALID_ENTITY) = 0; /** * Register a unit shape. * * @param ent entity ID associated with this shape (or INVALID_ENTITY if none) * @param x,z coordinates of center, in world space * @param clearance pathfinding clearance of the unit (works as a radius) * @param flags a set of EFlags values * @param group control group (typically the owner entity, or a formation controller entity * - units ignore collisions with others in the same group) * @return a valid tag for manipulating the shape * @see UnitShape */ virtual tag_t AddUnitShape(entity_id_t ent, entity_pos_t x, entity_pos_t z, entity_pos_t clearance, flags_t flags, entity_id_t group) = 0; /** * Adjust the position and angle of an existing shape. * @param tag tag of shape (must be valid) * @param x X coordinate of center, in world space * @param z Z coordinate of center, in world space * @param a angle of rotation (clockwise from +Z direction); ignored for unit shapes */ virtual void MoveShape(tag_t tag, entity_pos_t x, entity_pos_t z, entity_angle_t a) = 0; /** * Set whether a unit shape is moving or stationary. * @param tag tag of shape (must be valid and a unit shape) * @param moving whether the unit is currently moving through the world or is stationary */ virtual void SetUnitMovingFlag(tag_t tag, bool moving) = 0; /** * Set the control group of a unit shape. * @param tag tag of shape (must be valid and a unit shape) * @param group control group entity ID */ virtual void SetUnitControlGroup(tag_t tag, entity_id_t group) = 0; /** * Sets the control group of a static shape. * @param tag Tag of the shape to set the control group for. Must be a valid and static shape tag. * @param group Control group entity ID. */ virtual void SetStaticControlGroup(tag_t tag, entity_id_t group, entity_id_t group2) = 0; /** * Remove an existing shape. The tag will be made invalid and must not be used after this. * @param tag tag of shape (must be valid) */ virtual void RemoveShape(tag_t tag) = 0; /** * Returns the distance from the obstruction to the point (px, pz), or -1 if the entity is out of the world. */ virtual fixed DistanceToPoint(entity_id_t ent, entity_pos_t px, entity_pos_t pz) const = 0; /** * Calculate the largest straight line distance between the entity and the point. */ virtual fixed MaxDistanceToPoint(entity_id_t ent, entity_pos_t px, entity_pos_t pz) const = 0; /** * Calculate the shortest distance between the entity and the target. */ virtual fixed DistanceToTarget(entity_id_t ent, entity_id_t target) const = 0; /** * Calculate the largest straight line distance between the entity and the target. */ virtual fixed MaxDistanceToTarget(entity_id_t ent, entity_id_t target) const = 0; /** * Calculate the shortest straight line distance between the source and the target */ virtual fixed DistanceBetweenShapes(const ObstructionSquare& source, const ObstructionSquare& target) const = 0; /** * Calculate the largest straight line distance between the source and the target */ virtual fixed MaxDistanceBetweenShapes(const ObstructionSquare& source, const ObstructionSquare& target) const = 0; /** * Check if the given entity is in range of the other point given those parameters. - * @param maxRange - if -1, treated as infinite. + * @param maxRange - Can be a nonnegative decimal, ALWAYS_IN_RANGE or NEVER_IN_RANGE. */ virtual bool IsInPointRange(entity_id_t ent, entity_pos_t px, entity_pos_t pz, entity_pos_t minRange, entity_pos_t maxRange, bool opposite) const = 0; /** * Check if the given entity is in range of the target given those parameters. - * @param maxRange - if -1, treated as infinite. + * @param maxRange - Can be a nonnegative decimal, ALWAYS_IN_RANGE or NEVER_IN_RANGE. */ virtual bool IsInTargetRange(entity_id_t ent, entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange, bool opposite) const = 0; /** + * Check if the given entity is in parabolic range of the target given those parameters. + * @param maxRange - Can be a nonnegative decimal, ALWAYS_IN_RANGE or NEVER_IN_RANGE. + */ + virtual bool IsInTargetParabolicRange(entity_id_t ent, entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t yOrigin, bool opposite) const = 0; + + /** * Check if the given point is in range of the other point given those parameters. - * @param maxRange - if -1, treated as infinite. + * @param maxRange - Can be a nonnegative decimal, ALWAYS_IN_RANGE or NEVER_IN_RANGE. */ virtual bool IsPointInPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t px, entity_pos_t pz, entity_pos_t minRange, entity_pos_t maxRange) const = 0; /** * Check if the given shape is in range of the target shape given those parameters. - * @param maxRange - if -1, treated as infinite. + * @param maxRange - Can be a nonnegative decimal, ALWAYS_IN_RANGE or NEVER_IN_RANGE. */ virtual bool AreShapesInRange(const ObstructionSquare& source, const ObstructionSquare& target, entity_pos_t minRange, entity_pos_t maxRange, bool opposite) const = 0; /** * Collision test a flat-ended thick line against the current set of shapes. * The line caps extend by @p r beyond the end points. * Only intersections going from outside to inside a shape are counted. * @param filter filter to restrict the shapes that are counted * @param x0 X coordinate of line's first point * @param z0 Z coordinate of line's first point * @param x1 X coordinate of line's second point * @param z1 Z coordinate of line's second point * @param r radius (half width) of line * @param relaxClearanceForUnits whether unit-unit collisions should be more permissive. * @return true if there is a collision */ virtual bool TestLine(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, entity_pos_t r, bool relaxClearanceForUnits) const = 0; /** * Collision test a static square shape against the current set of shapes. * @param filter filter to restrict the shapes that are being tested against * @param x X coordinate of center * @param z Z coordinate of center * @param a angle of rotation (clockwise from +Z direction) * @param w width (size along X axis) * @param h height (size along Z axis) * @param out if non-NULL, all colliding shapes' entities will be added to this list * @return true if there is a collision */ virtual bool TestStaticShape(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t a, entity_pos_t w, entity_pos_t h, std::vector* out) const = 0; /** * Collision test a unit shape against the current set of registered shapes, and optionally writes a list of the colliding * shapes' entities to an output list. * * @param filter filter to restrict the shapes that are being tested against * @param x X coordinate of shape's center * @param z Z coordinate of shape's center * @param clearance clearance of the shape's unit * @param out if non-NULL, all colliding shapes' entities will be added to this list * * @return true if there is a collision */ virtual bool TestUnitShape(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t clearance, std::vector* out) const = 0; /** * Convert the current set of shapes onto a navcell grid, for all passability classes contained in @p passClasses. * If @p fullUpdate is false, the function will only go through dirty shapes. * Shapes are expanded by the @p passClasses clearances, by ORing their masks onto the @p grid. */ virtual void Rasterize(Grid& grid, const std::vector& passClasses, bool fullUpdate) = 0; /** * Gets dirtiness information and resets it afterwards. Then it's the role of CCmpPathfinder * to pass the information to other components if needed. (AIs, etc.) * The return value is false if an update is unnecessary. */ virtual void UpdateInformations(GridUpdateInformation& informations) = 0; /** * Find all the obstructions that are inside (or partially inside) the given range. * @param filter filter to restrict the shapes that are counted * @param x0 X coordinate of left edge of range * @param z0 Z coordinate of bottom edge of range * @param x1 X coordinate of right edge of range * @param z1 Z coordinate of top edge of range * @param squares output list of obstructions */ virtual void GetObstructionsInRange(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, std::vector& squares) const = 0; virtual void GetStaticObstructionsInRange(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, std::vector& squares) const = 0; virtual void GetUnitObstructionsInRange(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, std::vector& squares) const = 0; virtual void GetStaticObstructionsOnObstruction(const ObstructionSquare& square, std::vector& out, const IObstructionTestFilter& filter) const = 0; /** * Returns the entity IDs of all unit shapes that intersect the given * obstruction square, filtering out using the given filter. * @param square the Obstruction squre we want to compare with. * @param out output list of obstructions * @param filter filter for the obstructing units * @param strict whether to be strict in the check or more permissive (ie rasterize more or less). Default false. */ virtual void GetUnitsOnObstruction(const ObstructionSquare& square, std::vector& out, const IObstructionTestFilter& filter, bool strict = false) const = 0; /** * Get the obstruction square representing the given shape. * @param tag tag of shape (must be valid) */ virtual ObstructionSquare GetObstruction(tag_t tag) const = 0; virtual ObstructionSquare GetUnitShapeObstruction(entity_pos_t x, entity_pos_t z, entity_pos_t clearance) const = 0; virtual ObstructionSquare GetStaticShapeObstruction(entity_pos_t x, entity_pos_t z, entity_angle_t a, entity_pos_t w, entity_pos_t h) const = 0; /** * Set the passability to be restricted to a circular map. */ virtual void SetPassabilityCircular(bool enabled) = 0; virtual bool GetPassabilityCircular() const = 0; /** * Toggle the rendering of debug info. */ virtual void SetDebugOverlay(bool enabled) = 0; DECLARE_INTERFACE_TYPE(ObstructionManager) }; /** * Interface for ICmpObstructionManager @c Test functions to filter out unwanted shapes. */ class IObstructionTestFilter { public: typedef ICmpObstructionManager::tag_t tag_t; typedef ICmpObstructionManager::flags_t flags_t; virtual ~IObstructionTestFilter() {} /** * Return true if the shape with the specified parameters should be tested for collisions. * This is called for all shapes that would collide, and also for some that wouldn't. * * @param tag tag of shape being tested * @param flags set of EFlags for the shape * @param group the control group of the shape (typically the shape's unit, or the unit's formation controller, or 0) * @param group2 an optional secondary control group of the shape, or INVALID_ENTITY if none specified. Currently * exists only for static shapes. */ virtual bool TestShape(tag_t tag, flags_t flags, entity_id_t group, entity_id_t group2) const = 0; }; /** * Obstruction test filter that will test against all shapes. */ class NullObstructionFilter : public IObstructionTestFilter { public: virtual bool TestShape(tag_t UNUSED(tag), flags_t UNUSED(flags), entity_id_t UNUSED(group), entity_id_t UNUSED(group2)) const { return true; } }; /** * Obstruction test filter that will test only against stationary (i.e. non-moving) shapes. */ class StationaryOnlyObstructionFilter : public IObstructionTestFilter { public: virtual bool TestShape(tag_t UNUSED(tag), flags_t flags, entity_id_t UNUSED(group), entity_id_t UNUSED(group2)) const { return !(flags & ICmpObstructionManager::FLAG_MOVING); } }; /** * Obstruction test filter that reject shapes in a given control group, * and rejects shapes that don't block unit movement, and optionally rejects moving shapes. */ class ControlGroupMovementObstructionFilter : public IObstructionTestFilter { bool m_AvoidMoving; entity_id_t m_Group; public: ControlGroupMovementObstructionFilter(bool avoidMoving, entity_id_t group) : m_AvoidMoving(avoidMoving), m_Group(group) {} virtual bool TestShape(tag_t UNUSED(tag), flags_t flags, entity_id_t group, entity_id_t group2) const { if (group == m_Group || (group2 != INVALID_ENTITY && group2 == m_Group)) return false; if (!(flags & ICmpObstructionManager::FLAG_BLOCK_MOVEMENT)) return false; if ((flags & ICmpObstructionManager::FLAG_MOVING) && !m_AvoidMoving) return false; return true; } }; /** * Obstruction test filter that will test only against shapes that: * - are part of neither one of the specified control groups * - AND, depending on the value of the 'exclude' argument: * - have at least one of the specified flags set. * - OR have none of the specified flags set. * * The first (primary) control group to reject shapes from must be specified and valid. The secondary * control group to reject entities from may be set to INVALID_ENTITY to not use it. * * This filter is useful to e.g. allow foundations within the same control group to be placed and * constructed arbitrarily close together (e.g. for wall pieces that need to link up tightly). */ class SkipControlGroupsRequireFlagObstructionFilter : public IObstructionTestFilter { bool m_Exclude; entity_id_t m_Group; entity_id_t m_Group2; flags_t m_Mask; public: SkipControlGroupsRequireFlagObstructionFilter(bool exclude, entity_id_t group1, entity_id_t group2, flags_t mask) : m_Exclude(exclude), m_Group(group1), m_Group2(group2), m_Mask(mask) { Init(); } SkipControlGroupsRequireFlagObstructionFilter(entity_id_t group1, entity_id_t group2, flags_t mask) : m_Exclude(false), m_Group(group1), m_Group2(group2), m_Mask(mask) { Init(); } virtual bool TestShape(tag_t UNUSED(tag), flags_t flags, entity_id_t group, entity_id_t group2) const { // Don't test shapes that share one or more of our control groups. if (group == m_Group || group == m_Group2 || (group2 != INVALID_ENTITY && (group2 == m_Group || group2 == m_Group2))) return false; // If m_Exclude is true, don't test against shapes that have any of the // obstruction flags specified in m_Mask. if (m_Exclude) return (flags & m_Mask) == 0; // Otherwise, only include shapes that match at least one flag in m_Mask. return (flags & m_Mask) != 0; } private: void Init() { // the primary control group to filter out must be valid ENSURE(m_Group != INVALID_ENTITY); // for simplicity, if m_Group2 is INVALID_ENTITY (i.e. not used), then set it equal to m_Group // so that we have fewer special cases to consider in TestShape(). if (m_Group2 == INVALID_ENTITY) m_Group2 = m_Group; } }; /** * Obstruction test filter that will test only against shapes that: * - are part of both of the specified control groups * - AND have at least one of the specified flags set. * * The first (primary) control group to include shapes from must be specified and valid. * * This filter is useful for preventing entities with identical control groups * from colliding e.g. building a new wall segment on top of an existing wall) * * @todo This filter needs test cases. */ class SkipTagRequireControlGroupsAndFlagObstructionFilter : public IObstructionTestFilter { tag_t m_Tag; entity_id_t m_Group; entity_id_t m_Group2; flags_t m_Mask; public: SkipTagRequireControlGroupsAndFlagObstructionFilter(tag_t tag, entity_id_t group1, entity_id_t group2, flags_t mask) : m_Tag(tag), m_Group(group1), m_Group2(group2), m_Mask(mask) { ENSURE(m_Group != INVALID_ENTITY); } virtual bool TestShape(tag_t tag, flags_t flags, entity_id_t group, entity_id_t group2) const { // To be included in testing, a shape must not have the specified tag, and must // match at least one of the flags in m_Mask, as well as both control groups. return (tag.n != m_Tag.n && (flags & m_Mask) != 0 && ((group == m_Group && group2 == m_Group2) || (group2 == m_Group && group == m_Group2))); } }; /** * Obstruction test filter that will test only against shapes that do not have the specified tag set. */ class SkipTagObstructionFilter : public IObstructionTestFilter { tag_t m_Tag; public: SkipTagObstructionFilter(tag_t tag) : m_Tag(tag) { } virtual bool TestShape(tag_t tag, flags_t UNUSED(flags), entity_id_t UNUSED(group), entity_id_t UNUSED(group2)) const { return tag.n != m_Tag.n; } }; /** * Similar to ControlGroupMovementObstructionFilter, but also ignoring a specific tag. See D3482 for why this exists. */ class SkipTagAndControlGroupObstructionFilter : public IObstructionTestFilter { entity_id_t m_Group; tag_t m_Tag; bool m_AvoidMoving; public: SkipTagAndControlGroupObstructionFilter(tag_t tag, bool avoidMoving, entity_id_t group) : m_Tag(tag), m_Group(group), m_AvoidMoving(avoidMoving) {} virtual bool TestShape(tag_t tag, flags_t flags, entity_id_t group, entity_id_t group2) const { if (tag.n == m_Tag.n) return false; if (group == m_Group || (group2 != INVALID_ENTITY && group2 == m_Group)) return false; if ((flags & ICmpObstructionManager::FLAG_MOVING) && !m_AvoidMoving) return false; if (!(flags & ICmpObstructionManager::FLAG_BLOCK_MOVEMENT)) return false; return true; } }; /** * Obstruction test filter that will test only against shapes that: * - do not have the specified tag * - AND have at least one of the specified flags set. */ class SkipTagRequireFlagsObstructionFilter : public IObstructionTestFilter { tag_t m_Tag; flags_t m_Mask; public: SkipTagRequireFlagsObstructionFilter(tag_t tag, flags_t mask) : m_Tag(tag), m_Mask(mask) { } virtual bool TestShape(tag_t tag, flags_t flags, entity_id_t UNUSED(group), entity_id_t UNUSED(group2)) const { return (tag.n != m_Tag.n && (flags & m_Mask) != 0); } }; #endif // INCLUDED_ICMPOBSTRUCTIONMANAGER Index: ps/trunk/source/simulation2/components/ICmpRangeManager.cpp =================================================================== --- ps/trunk/source/simulation2/components/ICmpRangeManager.cpp (revision 26391) +++ ps/trunk/source/simulation2/components/ICmpRangeManager.cpp (revision 26392) @@ -1,77 +1,78 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * 0 A.D. is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with 0 A.D. If not, see . */ #include "precompiled.h" #include "ICmpRangeManager.h" #include "simulation2/system/InterfaceScripted.h" namespace { std::string VisibilityToString(LosVisibility visibility) { switch (visibility) { case LosVisibility::HIDDEN: return "hidden"; case LosVisibility::FOGGED: return "fogged"; case LosVisibility::VISIBLE: return "visible"; default: return "error"; // should never happen } } } std::string ICmpRangeManager::GetLosVisibility_wrapper(entity_id_t ent, int player) const { return VisibilityToString(GetLosVisibility(ent, player)); } std::string ICmpRangeManager::GetLosVisibilityPosition_wrapper(entity_pos_t x, entity_pos_t z, int player) const { return VisibilityToString(GetLosVisibilityPosition(x, z, player)); } BEGIN_INTERFACE_WRAPPER(RangeManager) DEFINE_INTERFACE_METHOD("ExecuteQuery", ICmpRangeManager, ExecuteQuery) DEFINE_INTERFACE_METHOD("ExecuteQueryAroundPos", ICmpRangeManager, ExecuteQueryAroundPos) DEFINE_INTERFACE_METHOD("CreateActiveQuery", ICmpRangeManager, CreateActiveQuery) DEFINE_INTERFACE_METHOD("CreateActiveParabolicQuery", ICmpRangeManager, CreateActiveParabolicQuery) DEFINE_INTERFACE_METHOD("DestroyActiveQuery", ICmpRangeManager, DestroyActiveQuery) DEFINE_INTERFACE_METHOD("EnableActiveQuery", ICmpRangeManager, EnableActiveQuery) DEFINE_INTERFACE_METHOD("DisableActiveQuery", ICmpRangeManager, DisableActiveQuery) DEFINE_INTERFACE_METHOD("IsActiveQueryEnabled", ICmpRangeManager, IsActiveQueryEnabled) DEFINE_INTERFACE_METHOD("ResetActiveQuery", ICmpRangeManager, ResetActiveQuery) DEFINE_INTERFACE_METHOD("SetEntityFlag", ICmpRangeManager, SetEntityFlag) DEFINE_INTERFACE_METHOD("GetEntityFlagMask", ICmpRangeManager, GetEntityFlagMask) DEFINE_INTERFACE_METHOD("GetEntitiesByPlayer", ICmpRangeManager, GetEntitiesByPlayer) DEFINE_INTERFACE_METHOD("GetNonGaiaEntities", ICmpRangeManager, GetNonGaiaEntities) DEFINE_INTERFACE_METHOD("GetGaiaAndNonGaiaEntities", ICmpRangeManager, GetGaiaAndNonGaiaEntities) DEFINE_INTERFACE_METHOD("SetDebugOverlay", ICmpRangeManager, SetDebugOverlay) DEFINE_INTERFACE_METHOD("ExploreMap", ICmpRangeManager, ExploreMap) DEFINE_INTERFACE_METHOD("ExploreTerritories", ICmpRangeManager, ExploreTerritories) DEFINE_INTERFACE_METHOD("SetLosRevealAll", ICmpRangeManager, SetLosRevealAll) DEFINE_INTERFACE_METHOD("GetLosRevealAll", ICmpRangeManager, GetLosRevealAll) +DEFINE_INTERFACE_METHOD("GetEffectiveParabolicRange", ICmpRangeManager, GetEffectiveParabolicRange) DEFINE_INTERFACE_METHOD("GetElevationAdaptedRange", ICmpRangeManager, GetElevationAdaptedRange) DEFINE_INTERFACE_METHOD("ActivateScriptedVisibility", ICmpRangeManager, ActivateScriptedVisibility) DEFINE_INTERFACE_METHOD("GetLosVisibility", ICmpRangeManager, GetLosVisibility_wrapper) DEFINE_INTERFACE_METHOD("GetLosVisibilityPosition", ICmpRangeManager, GetLosVisibilityPosition_wrapper) DEFINE_INTERFACE_METHOD("RequestVisibilityUpdate", ICmpRangeManager, RequestVisibilityUpdate) DEFINE_INTERFACE_METHOD("SetLosCircular", ICmpRangeManager, SetLosCircular) DEFINE_INTERFACE_METHOD("GetLosCircular", ICmpRangeManager, GetLosCircular) DEFINE_INTERFACE_METHOD("SetSharedLos", ICmpRangeManager, SetSharedLos) DEFINE_INTERFACE_METHOD("GetPercentMapExplored", ICmpRangeManager, GetPercentMapExplored) DEFINE_INTERFACE_METHOD("GetUnionPercentMapExplored", ICmpRangeManager, GetUnionPercentMapExplored) END_INTERFACE_WRAPPER(RangeManager) Index: ps/trunk/source/simulation2/components/ICmpRangeManager.h =================================================================== --- ps/trunk/source/simulation2/components/ICmpRangeManager.h (revision 26391) +++ ps/trunk/source/simulation2/components/ICmpRangeManager.h (revision 26392) @@ -1,372 +1,394 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * 0 A.D. is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with 0 A.D. If not, see . */ #ifndef INCLUDED_ICMPRANGEMANAGER #define INCLUDED_ICMPRANGEMANAGER #include "maths/FixedVector3D.h" #include "maths/FixedVector2D.h" #include "simulation2/system/Interface.h" #include "simulation2/helpers/Position.h" #include "simulation2/helpers/Player.h" #include class FastSpatialSubdivision; /** + * Value assigned to a range we will always be in (caused by out of world or "too high" in parabolic ranges). + * TODO Add this for minRanges too. + */ +const entity_pos_t ALWAYS_IN_RANGE = entity_pos_t::FromInt(-1); + +/** + * Value assigned to a range we will never be in (caused by out of world or "too high" in parabolic ranges). + * TODO Add this to range queries too. + */ +const entity_pos_t NEVER_IN_RANGE = entity_pos_t::FromInt(-2); + +/** * Since GetVisibility queries are run by the range manager * other code using these must include ICmpRangeManager.h anyways, * so define this enum here (Ideally, it'd be in its own header file, * but adding header file does incur its own compilation time increase). */ enum class LosVisibility : u8 { HIDDEN = 0, FOGGED = 1, VISIBLE = 2 }; /** * The same principle applies to CLosQuerier, but to avoid recompiling TUs (a fortiori headers) * dependent on RangeManager but not CLosQuerier when CLosQuerier changes, * we define it in another file. Code using LOS will then explicitly include the LOS header * which makes sense anyways. */ class CLosQuerier; /** * Provides efficient range-based queries of the game world, * and also LOS-based effects (fog of war). * * (These are somewhat distinct concepts but they share a lot of the implementation, * so for efficiency they're combined into this class.) * * Possible use cases: * - combat units need to detect targetable enemies entering LOS, so they can choose * to auto-attack. * - auras let a unit have some effect on all units (or those of the same player, or of enemies) * within a certain range. * - capturable animals need to detect when a player-owned unit is nearby and no units of other * players are in range. * - scenario triggers may want to detect when units enter a given area. * - units gathering from a resource that is exhausted need to find a new resource of the * same type, near the old one and reachable. * - projectile weapons with splash damage need to find all units within some distance * of the target point. * - ... * * In most cases the users are event-based and want notifications when something * has entered or left the range, and the query can be set up once and rarely changed. * These queries have to be fast. Entities are approximated as points or circles * (queries can be set up to ignore sizes because LOS currently ignores it, and mismatches are problematic). * * Current design: * * This class handles just the most common parts of range queries: * distance, target interface, and player ownership. * The caller can then apply any more complex filtering that it needs. * * There are two types of query: * Passive queries are performed by ExecuteQuery and immediately return the matching entities. * Active queries are set up by CreateActiveQuery, and then a CMessageRangeUpdate message will be * sent to the entity once per turn if anybody has entered or left the range since the last RangeUpdate. * Queries can be disabled, in which case no message will be sent. */ class ICmpRangeManager : public IComponent { public: /** * External identifiers for active queries. */ typedef u32 tag_t; /** * Access the spatial subdivision kept by the range manager. * @return pointer to spatial subdivision structure. */ virtual FastSpatialSubdivision* GetSubdivision() = 0; /** * Set the bounds of the world. * Entities should not be outside the bounds (else efficiency will suffer). * @param x0,z0,x1,z1 Coordinates of the corners of the world */ virtual void SetBounds(entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1) = 0; /** * Execute a passive query. * @param source the entity around which the range will be computed. * @param minRange non-negative minimum distance in metres (inclusive). * @param maxRange non-negative maximum distance in metres (inclusive); or -1.0 to ignore distance. * @param owners list of player IDs that matching entities may have; -1 matches entities with no owner. * @param requiredInterface if non-zero, an interface ID that matching entities must implement. * @param accountForSize if true, compensate for source/target entity sizes. * @return list of entities matching the query, ordered by increasing distance from the source entity. */ virtual std::vector ExecuteQuery(entity_id_t source, entity_pos_t minRange, entity_pos_t maxRange, const std::vector& owners, int requiredInterface, bool accountForSize) = 0; /** * Execute a passive query. * @param pos the position around which the range will be computed. * @param minRange non-negative minimum distance in metres (inclusive). * @param maxRange non-negative maximum distance in metres (inclusive); or -1.0 to ignore distance. * @param owners list of player IDs that matching entities may have; -1 matches entities with no owner. * @param requiredInterface if non-zero, an interface ID that matching entities must implement. * @param accountForSize if true, compensate for source/target entity sizes. * @return list of entities matching the query, ordered by increasing distance from the source entity. */ virtual std::vector ExecuteQueryAroundPos(const CFixedVector2D& pos, entity_pos_t minRange, entity_pos_t maxRange, const std::vector& owners, int requiredInterface, bool accountForSize) = 0; /** * Construct an active query. The query will be disabled by default. * @param source the entity around which the range will be computed. * @param minRange non-negative minimum distance in metres (inclusive). * @param maxRange non-negative maximum distance in metres (inclusive); or -1.0 to ignore distance. * @param owners list of player IDs that matching entities may have; -1 matches entities with no owner. * @param requiredInterface if non-zero, an interface ID that matching entities must implement. * @param flags if a entity in range has one of the flags set it will show up. * @param accountForSize if true, compensate for source/target entity sizes. * @return unique non-zero identifier of query. */ virtual tag_t CreateActiveQuery(entity_id_t source, entity_pos_t minRange, entity_pos_t maxRange, const std::vector& owners, int requiredInterface, u8 flags, bool accountForSize) = 0; /** * Construct an active query of a paraboloic form around the unit. * The query will be disabled by default. * @param source the entity around which the range will be computed. * @param minRange non-negative minimum horizontal distance in metres (inclusive). MinRange doesn't do parabolic checks. * @param maxRange non-negative maximum distance in metres (inclusive) for units on the same elevation; * or -1.0 to ignore distance. - * For units on a different elevation, a physical correct paraboloid with height=maxRange/2 above the unit is used to query them - * @param elevationBonus extra bonus so the source can be placed higher and shoot further + * For units on a different height positions, a physical correct paraboloid with height=maxRange/2 above the unit is used to query them + * @param yOrigin extra bonus so the source can be placed higher and shoot further * @param owners list of player IDs that matching entities may have; -1 matches entities with no owner. * @param requiredInterface if non-zero, an interface ID that matching entities must implement. * @param flags if a entity in range has one of the flags set it will show up. * NB: this one has no accountForSize parameter (assumed true), because we currently can only have 7 arguments for JS functions. * @return unique non-zero identifier of query. */ - virtual tag_t CreateActiveParabolicQuery(entity_id_t source, entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t elevationBonus, + virtual tag_t CreateActiveParabolicQuery(entity_id_t source, entity_pos_t minRange, entity_pos_t maxRange, entity_pos_t yOrigin, const std::vector& owners, int requiredInterface, u8 flags) = 0; /** + * Get the effective range in a parablic range query. + * @param source The entity id at the origin of the query. + * @param target A target entity id. + * @param range The distance to compare terrain height with. + * @param yOrigin Height the source gains over the target by default. + * @return a fixed number representing the effective range correcting parabolicly for the height difference. Returns -1 when the target is too high compared to the source to be in range. + */ + virtual entity_pos_t GetEffectiveParabolicRange(entity_id_t source, entity_id_t target, entity_pos_t range, entity_pos_t yOrigin) const = 0; + + /** * Get the average elevation over 8 points on distance range around the entity * @param id the entity id to look around * @param range the distance to compare terrain height with * @return a fixed number representing the average difference. It's positive when the entity is on average higher than the terrain surrounding it. */ - virtual entity_pos_t GetElevationAdaptedRange(const CFixedVector3D& pos, const CFixedVector3D& rot, entity_pos_t range, entity_pos_t elevationBonus, entity_pos_t angle) const = 0; + virtual entity_pos_t GetElevationAdaptedRange(const CFixedVector3D& pos, const CFixedVector3D& rot, entity_pos_t range, entity_pos_t yOrigin, entity_pos_t angle) const = 0; /** * Destroy a query and clean up resources. This must be called when an entity no longer needs its * query (e.g. when the entity is destroyed). * @param tag identifier of query. */ virtual void DestroyActiveQuery(tag_t tag) = 0; /** * Re-enable the processing of a query. * @param tag identifier of query. */ virtual void EnableActiveQuery(tag_t tag) = 0; /** * Disable the processing of a query (no RangeUpdate messages will be sent). * @param tag identifier of query. */ virtual void DisableActiveQuery(tag_t tag) = 0; /** * Check if the processing of a query is enabled. * @param tag identifier of a query. */ virtual bool IsActiveQueryEnabled(tag_t tag) const = 0; /** * Immediately execute a query, and re-enable it if disabled. * The next RangeUpdate message will say who has entered/left since this call, * so you won't miss any notifications. * @param tag identifier of query. * @return list of entities matching the query, ordered by increasing distance from the source entity. */ virtual std::vector ResetActiveQuery(tag_t tag) = 0; /** * Returns a list of all entities for a specific player. * (This is on this interface because it shares a lot of the implementation. * Maybe it should be extended to be more like ExecuteQuery without * the range parameter.) */ virtual std::vector GetEntitiesByPlayer(player_id_t player) const = 0; /** * Returns a list of all entities of all players except gaia. */ virtual std::vector GetNonGaiaEntities() const = 0; /** * Returns a list of all entities owned by a player or gaia. */ virtual std::vector GetGaiaAndNonGaiaEntities() const = 0; /** * Toggle the rendering of debug info. */ virtual void SetDebugOverlay(bool enabled) = 0; /** * Returns the mask for the specified identifier. */ virtual u8 GetEntityFlagMask(const std::string& identifier) const = 0; /** * Set the flag specified by the identifier to the supplied value for the entity * @param ent the entity whose flags will be modified. * @param identifier the flag to be modified. * @param value to which the flag will be set. */ virtual void SetEntityFlag(entity_id_t ent, const std::string& identifier, bool value) = 0; ////////////////////////////////////////////////////////////////// //// LOS interface below this line //// ////////////////////////////////////////////////////////////////// /** * Returns a CLosQuerier for checking whether vertex positions are visible to the given player * (or other players it shares LOS with). */ virtual CLosQuerier GetLosQuerier(player_id_t player) const = 0; /** * Toggle the scripted Visibility component activation for entity ent. */ virtual void ActivateScriptedVisibility(entity_id_t ent, bool status) = 0; /** * Returns the visibility status of the given entity, with respect to the given player. * Returns LosVisibility::HIDDEN if the entity doesn't exist or is not in the world. * This respects the GetLosRevealAll flag. */ virtual LosVisibility GetLosVisibility(CEntityHandle ent, player_id_t player) const = 0; virtual LosVisibility GetLosVisibility(entity_id_t ent, player_id_t player) const = 0; /** * Returns the visibility status of the given position, with respect to the given player. * This respects the GetLosRevealAll flag. */ virtual LosVisibility GetLosVisibilityPosition(entity_pos_t x, entity_pos_t z, player_id_t player) const = 0; /** * Request the update of the visibility cache of ent at next turn. * Typically used for fogging. */ virtual void RequestVisibilityUpdate(entity_id_t ent) = 0; /** * GetLosVisibility wrapped for script calls. * Returns "hidden", "fogged" or "visible". */ std::string GetLosVisibility_wrapper(entity_id_t ent, player_id_t player) const; /** * GetLosVisibilityPosition wrapped for script calls. * Returns "hidden", "fogged" or "visible". */ std::string GetLosVisibilityPosition_wrapper(entity_pos_t x, entity_pos_t z, player_id_t player) const; /** * Explore the map (but leave it in the FoW) for player p */ virtual void ExploreMap(player_id_t p) = 0; /** * Explore the tiles inside each player's territory. * This is done only at the beginning of the game. */ virtual void ExploreTerritories() = 0; /** * Reveal the shore for specified player p. * This works like for entities: if RevealShore is called multiple times with enabled, it * will be necessary to call it the same number of times with !enabled to make the shore * fall back into the FoW. */ virtual void RevealShore(player_id_t p, bool enable) = 0; /** * Set whether the whole map should be made visible to the given player. * If player is -1, the map will be made visible to all players. */ virtual void SetLosRevealAll(player_id_t player, bool enabled) = 0; /** * Returns whether the whole map has been made visible to the given player. */ virtual bool GetLosRevealAll(player_id_t player) const = 0; /** * Set the LOS to be restricted to a circular map. */ virtual void SetLosCircular(bool enabled) = 0; /** * Returns whether the LOS is restricted to a circular map. */ virtual bool GetLosCircular() const = 0; /** * Sets shared LOS data for player to the given list of players. */ virtual void SetSharedLos(player_id_t player, const std::vector& players) = 0; /** * Returns shared LOS mask for player. */ virtual u32 GetSharedLosMask(player_id_t player) const = 0; /** * Get percent map explored statistics for specified player. */ virtual u8 GetPercentMapExplored(player_id_t player) const = 0; /** * Get percent map explored statistics for specified set of players. * Note: this function computes statistics from scratch and should not be called too often. */ virtual u8 GetUnionPercentMapExplored(const std::vector& players) const = 0; /** * @return The number of LOS vertices. */ virtual size_t GetVerticesPerSide() const = 0; /** * Perform some internal consistency checks for testing/debugging. */ virtual void Verify() = 0; DECLARE_INTERFACE_TYPE(RangeManager) }; #endif // INCLUDED_ICMPRANGEMANAGER Index: ps/trunk/source/simulation2/components/tests/test_RangeManager.h =================================================================== --- ps/trunk/source/simulation2/components/tests/test_RangeManager.h (revision 26391) +++ ps/trunk/source/simulation2/components/tests/test_RangeManager.h (revision 26392) @@ -1,271 +1,313 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2022 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * 0 A.D. is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with 0 A.D. If not, see . */ #include "maths/Matrix3D.h" #include "simulation2/system/ComponentTest.h" #include "simulation2/components/ICmpRangeManager.h" #include "simulation2/components/ICmpObstruction.h" #include "simulation2/components/ICmpPosition.h" #include "simulation2/components/ICmpVision.h" #include #include class MockVisionRgm : public ICmpVision { public: DEFAULT_MOCK_COMPONENT() virtual entity_pos_t GetRange() const { return entity_pos_t::FromInt(66); } virtual bool GetRevealShore() const { return false; } }; class MockPositionRgm : public ICmpPosition { public: DEFAULT_MOCK_COMPONENT() virtual void SetTurretParent(entity_id_t UNUSED(id), const CFixedVector3D& UNUSED(pos)) {} virtual entity_id_t GetTurretParent() const {return INVALID_ENTITY;} virtual void UpdateTurretPosition() {} virtual std::set* GetTurrets() { return NULL; } virtual bool IsInWorld() const { return true; } virtual void MoveOutOfWorld() { } virtual void MoveTo(entity_pos_t UNUSED(x), entity_pos_t UNUSED(z)) { } virtual void MoveAndTurnTo(entity_pos_t UNUSED(x), entity_pos_t UNUSED(z), entity_angle_t UNUSED(a)) { } virtual void JumpTo(entity_pos_t UNUSED(x), entity_pos_t UNUSED(z)) { } virtual void SetHeightOffset(entity_pos_t UNUSED(dy)) { } virtual entity_pos_t GetHeightOffset() const { return entity_pos_t::Zero(); } virtual void SetHeightFixed(entity_pos_t UNUSED(y)) { } virtual entity_pos_t GetHeightFixed() const { return entity_pos_t::Zero(); } virtual entity_pos_t GetHeightAtFixed(entity_pos_t, entity_pos_t) const { return entity_pos_t::Zero(); } virtual bool IsHeightRelative() const { return true; } virtual void SetHeightRelative(bool UNUSED(relative)) { } virtual bool CanFloat() const { return false; } virtual void SetFloating(bool UNUSED(flag)) { } virtual void SetActorFloating(bool UNUSED(flag)) { } virtual void SetConstructionProgress(fixed UNUSED(progress)) { } virtual CFixedVector3D GetPosition() const { return m_Pos; } virtual CFixedVector2D GetPosition2D() const { return CFixedVector2D(m_Pos.X, m_Pos.Z); } virtual CFixedVector3D GetPreviousPosition() const { return CFixedVector3D(); } virtual CFixedVector2D GetPreviousPosition2D() const { return CFixedVector2D(); } virtual fixed GetTurnRate() const { return fixed::Zero(); } virtual void TurnTo(entity_angle_t UNUSED(y)) { } virtual void SetYRotation(entity_angle_t UNUSED(y)) { } virtual void SetXZRotation(entity_angle_t UNUSED(x), entity_angle_t UNUSED(z)) { } virtual CFixedVector3D GetRotation() const { return CFixedVector3D(); } virtual fixed GetDistanceTravelled() const { return fixed::Zero(); } virtual void GetInterpolatedPosition2D(float UNUSED(frameOffset), float& x, float& z, float& rotY) const { x = z = rotY = 0; } virtual CMatrix3D GetInterpolatedTransform(float UNUSED(frameOffset)) const { return CMatrix3D(); } CFixedVector3D m_Pos; }; class MockObstructionRgm : public ICmpObstruction { public: DEFAULT_MOCK_COMPONENT(); MockObstructionRgm(entity_pos_t s) : m_Size(s) {}; virtual ICmpObstructionManager::tag_t GetObstruction() const { return {}; }; virtual bool GetObstructionSquare(ICmpObstructionManager::ObstructionSquare&) const { return false; }; virtual bool GetPreviousObstructionSquare(ICmpObstructionManager::ObstructionSquare&) const { return false; }; virtual entity_pos_t GetSize() const { return m_Size; }; virtual CFixedVector2D GetStaticSize() const { return {}; }; virtual EObstructionType GetObstructionType() const { return {}; }; virtual void SetUnitClearance(const entity_pos_t&) {}; virtual bool IsControlPersistent() const { return {}; }; virtual bool CheckShorePlacement() const { return {}; }; virtual EFoundationCheck CheckFoundation(const std::string&) const { return {}; }; virtual EFoundationCheck CheckFoundation(const std::string& , bool) const { return {}; }; virtual std::string CheckFoundation_wrapper(const std::string&, bool) const { return {}; }; virtual bool CheckDuplicateFoundation() const { return {}; }; virtual std::vector GetEntitiesByFlags(ICmpObstructionManager::flags_t) const { return {}; }; virtual std::vector GetEntitiesBlockingMovement() const { return {}; }; virtual std::vector GetEntitiesBlockingConstruction() const { return {}; }; virtual std::vector GetEntitiesDeletedUponConstruction() const { return {}; }; virtual void ResolveFoundationCollisions() const {}; virtual void SetActive(bool) {}; virtual void SetMovingFlag(bool) {}; virtual void SetDisableBlockMovementPathfinding(bool, bool, int32_t) {}; virtual bool GetBlockMovementFlag(bool) const { return {}; }; virtual void SetControlGroup(entity_id_t) {}; virtual entity_id_t GetControlGroup() const { return {}; }; virtual void SetControlGroup2(entity_id_t) {}; virtual entity_id_t GetControlGroup2() const { return {}; }; private: entity_pos_t m_Size; }; class TestCmpRangeManager : public CxxTest::TestSuite { public: void setUp() { CXeromyces::Startup(); } void tearDown() { CXeromyces::Terminate(); } // TODO It would be nice to call Verify() with the shore revealing system // but that means testing on an actual map, with water and land. void test_basic() { ComponentTestHelper test(g_ScriptContext); ICmpRangeManager* cmp = test.Add(CID_RangeManager, "", SYSTEM_ENTITY); MockVisionRgm vision; test.AddMock(100, IID_Vision, vision); MockPositionRgm position; test.AddMock(100, IID_Position, position); // This tests that the incremental computation produces the correct result // in various edge cases cmp->SetBounds(entity_pos_t::FromInt(0), entity_pos_t::FromInt(0), entity_pos_t::FromInt(512), entity_pos_t::FromInt(512)); cmp->Verify(); { CMessageCreate msg(100); cmp->HandleMessage(msg, false); } cmp->Verify(); { CMessageOwnershipChanged msg(100, -1, 1); cmp->HandleMessage(msg, false); } cmp->Verify(); { CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(247), entity_pos_t::FromDouble(257.95), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); } cmp->Verify(); { CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(247), entity_pos_t::FromInt(253), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); } cmp->Verify(); { CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256), entity_pos_t::FromInt(256), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); } cmp->Verify(); { CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256)+entity_pos_t::Epsilon(), entity_pos_t::FromInt(256), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); } cmp->Verify(); { CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256)-entity_pos_t::Epsilon(), entity_pos_t::FromInt(256), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); } cmp->Verify(); { CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256), entity_pos_t::FromInt(256)+entity_pos_t::Epsilon(), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); } cmp->Verify(); { CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(256), entity_pos_t::FromInt(256)-entity_pos_t::Epsilon(), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); } cmp->Verify(); { CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(383), entity_pos_t::FromInt(84), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); } cmp->Verify(); { CMessagePositionChanged msg(100, true, entity_pos_t::FromInt(348), entity_pos_t::FromInt(83), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); } cmp->Verify(); boost::mt19937 rng; for (size_t i = 0; i < 1024; ++i) { double x = boost::random::uniform_real_distribution(0.0, 512.0)(rng); double z = boost::random::uniform_real_distribution(0.0, 512.0)(rng); { CMessagePositionChanged msg(100, true, entity_pos_t::FromDouble(x), entity_pos_t::FromDouble(z), entity_angle_t::Zero()); cmp->HandleMessage(msg, false); } cmp->Verify(); } // Test OwnershipChange, GetEntitiesByPlayer, GetNonGaiaEntities { player_id_t previousOwner = -1; for (player_id_t newOwner = 0; newOwner < 8; ++newOwner) { CMessageOwnershipChanged msg(100, previousOwner, newOwner); cmp->HandleMessage(msg, false); for (player_id_t i = 0; i < 8; ++i) TS_ASSERT_EQUALS(cmp->GetEntitiesByPlayer(i).size(), i == newOwner ? 1 : 0); TS_ASSERT_EQUALS(cmp->GetNonGaiaEntities().size(), newOwner > 0 ? 1 : 0); previousOwner = newOwner; } } } void test_queries() { ComponentTestHelper test(g_ScriptContext); ICmpRangeManager* cmp = test.Add(CID_RangeManager, "", SYSTEM_ENTITY); MockVisionRgm vision, vision2; MockPositionRgm position, position2; MockObstructionRgm obs(fixed::FromInt(2)), obs2(fixed::Zero()); test.AddMock(100, IID_Vision, vision); test.AddMock(100, IID_Position, position); test.AddMock(100, IID_Obstruction, obs); test.AddMock(101, IID_Vision, vision2); test.AddMock(101, IID_Position, position2); test.AddMock(101, IID_Obstruction, obs2); cmp->SetBounds(entity_pos_t::FromInt(0), entity_pos_t::FromInt(0), entity_pos_t::FromInt(512), entity_pos_t::FromInt(512)); cmp->Verify(); { CMessageCreate msg(100); cmp->HandleMessage(msg, false); } { CMessageCreate msg(101); cmp->HandleMessage(msg, false); } { CMessageOwnershipChanged msg(100, -1, 1); cmp->HandleMessage(msg, false); } { CMessageOwnershipChanged msg(101, -1, 1); cmp->HandleMessage(msg, false); } auto move = [&cmp](entity_id_t ent, MockPositionRgm& pos, fixed x, fixed z) { pos.m_Pos = CFixedVector3D(x, fixed::Zero(), z); { CMessagePositionChanged msg(ent, true, x, z, entity_angle_t::Zero()); cmp->HandleMessage(msg, false); } }; move(100, position, fixed::FromInt(10), fixed::FromInt(10)); move(101, position2, fixed::FromInt(10), fixed::FromInt(20)); std::vector nearby = cmp->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true); TS_ASSERT_EQUALS(nearby, std::vector{}); nearby = cmp->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {1}, 0, true); TS_ASSERT_EQUALS(nearby, std::vector{101}); move(101, position2, fixed::FromInt(10), fixed::FromInt(10)); nearby = cmp->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true); TS_ASSERT_EQUALS(nearby, std::vector{101}); nearby = cmp->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {1}, 0, true); TS_ASSERT_EQUALS(nearby, std::vector{}); move(101, position2, fixed::FromInt(10), fixed::FromInt(13)); nearby = cmp->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true); TS_ASSERT_EQUALS(nearby, std::vector{101}); nearby = cmp->ExecuteQuery(100, fixed::FromInt(4), fixed::FromInt(50), {1}, 0, true); TS_ASSERT_EQUALS(nearby, std::vector{}); move(101, position2, fixed::FromInt(10), fixed::FromInt(15)); // In range thanks to self obstruction size. nearby = cmp->ExecuteQuery(100, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true); TS_ASSERT_EQUALS(nearby, std::vector{101}); // In range thanks to target obstruction size. nearby = cmp->ExecuteQuery(101, fixed::FromInt(0), fixed::FromInt(4), {1}, 0, true); TS_ASSERT_EQUALS(nearby, std::vector{100}); // Trickier: min-range is closest-to-closest, but rotation may change the real distance. nearby = cmp->ExecuteQuery(100, fixed::FromInt(2), fixed::FromInt(50), {1}, 0, true); TS_ASSERT_EQUALS(nearby, std::vector{101}); nearby = cmp->ExecuteQuery(100, fixed::FromInt(5), fixed::FromInt(50), {1}, 0, true); TS_ASSERT_EQUALS(nearby, std::vector{101}); nearby = cmp->ExecuteQuery(100, fixed::FromInt(6), fixed::FromInt(50), {1}, 0, true); TS_ASSERT_EQUALS(nearby, std::vector{}); nearby = cmp->ExecuteQuery(101, fixed::FromInt(5), fixed::FromInt(50), {1}, 0, true); TS_ASSERT_EQUALS(nearby, std::vector{100}); nearby = cmp->ExecuteQuery(101, fixed::FromInt(6), fixed::FromInt(50), {1}, 0, true); TS_ASSERT_EQUALS(nearby, std::vector{}); } + + void test_IsInTargetParabolicRange() + { + ComponentTestHelper test(g_ScriptContext); + ICmpRangeManager* cmp = test.Add(CID_RangeManager, "", SYSTEM_ENTITY); + const entity_id_t source = 200; + const entity_id_t target = 201; + entity_pos_t range = fixed::FromInt(-3); + entity_pos_t yOrigin = fixed::FromInt(-20); + + // Invalid range. + TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), range); + + // No source ICmpPosition. + range = fixed::FromInt(10); + TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), NEVER_IN_RANGE); + + // No target ICmpPosition. + MockPositionRgm cmpSourcePosition; + test.AddMock(source, IID_Position, cmpSourcePosition); + TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), NEVER_IN_RANGE); + + // Too much height difference. + MockPositionRgm cmpTargetPosition; + test.AddMock(target, IID_Position, cmpTargetPosition); + TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), NEVER_IN_RANGE); + + // If no offset we get the range. + range = fixed::FromInt(20); + yOrigin = fixed::Zero(); + TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), range); + TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, fixed::Zero(), yOrigin), fixed::Zero()); + + // Normal case. + yOrigin = fixed::FromInt(5); + range = fixed::FromInt(10); + TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), fixed::FromFloat(14.142136f)); + + // Big range. + range = fixed::FromInt(260); + TS_ASSERT_EQUALS(cmp->GetEffectiveParabolicRange(source, target, range, yOrigin), fixed::FromFloat(264.952820f)); + } };