Index: ps/trunk/binaries/data/mods/public/maps/scenarios/pickup_test_map.pmp =================================================================== Cannot display: file marked as a binary type. svn:mime-type = application/octet-stream Index: ps/trunk/binaries/data/mods/public/maps/scenarios/pickup_test_map.pmp =================================================================== --- ps/trunk/binaries/data/mods/public/maps/scenarios/pickup_test_map.pmp (nonexistent) +++ ps/trunk/binaries/data/mods/public/maps/scenarios/pickup_test_map.pmp (revision 23925) Property changes on: ps/trunk/binaries/data/mods/public/maps/scenarios/pickup_test_map.pmp ___________________________________________________________________ Added: svn:mime-type ## -0,0 +1 ## +application/octet-stream \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/maps/scenarios/pickup_test_map.xml =================================================================== --- ps/trunk/binaries/data/mods/public/maps/scenarios/pickup_test_map.xml (nonexistent) +++ ps/trunk/binaries/data/mods/public/maps/scenarios/pickup_test_map.xml (revision 23925) @@ -0,0 +1,174 @@ + + + + + sunny + + + + + + + 0 + 0.5 + + + + + ocean + + + 168.772 + 1.36719 + 0.836914 + 0 + + + + 0 + 1 + 0.99 + 0.1999 + default + + + + + + + + + + + + 1 + + + + + + + 1 + + + + + + + 1 + + + + + + + 1 + + + + + + + 1 + + + + + + + 1 + + + + + + + 1 + + + + + + + 1 + + + + + + + 1 + + + + + + + 1 + + + + + + + 1 + + + + + + + 1 + + + + + + + 1 + + + + + + + 0 + + + + + + + 0 + + + + + + + Property changes on: ps/trunk/binaries/data/mods/public/maps/scenarios/pickup_test_map.xml ___________________________________________________________________ Added: svn:eol-style ## -0,0 +1 ## +native \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/maps/scenarios/pickup_test_map_triggers.js =================================================================== --- ps/trunk/binaries/data/mods/public/maps/scenarios/pickup_test_map_triggers.js (nonexistent) +++ ps/trunk/binaries/data/mods/public/maps/scenarios/pickup_test_map_triggers.js (revision 23925) @@ -0,0 +1,218 @@ +const UNIT_TEMPLATE = "units/athen_infantry_marine_archer_b"; +const SHIP_TEMPLATE = "units/athen_ship_trireme"; +const RAM_TEMPLATE = "units/brit_siege_ram"; + +const point_plaza_nw = 14; // Center plaza #1 (NW) +const point_plaza_ne = 15; // Center plaza #2 (NE) +const point_plaza_se = 16; // Center plaza #3 (SE) +const point_plaza_sw = 17; // Center plaza #4 (SW) +const point_road_1 = 18; // 'Road' from plaza to land end in the NW, #1 +const point_road_2 = 19; // 'Road' from plaza to land end in the NW, #2 +const point_road_3 = 20; // 'Road' from plaza to land end in the NW, #3 +const point_road_4 = 21; // 'Road' from plaza to land end in the NW, #4 +const point_road_5 = 22; // 'Road' from plaza to land end in the NW, #5 and last +const point_island_1 = 23; // Sand Island #1 (north) +const point_island_2 = 24; // Sand Island #2 (south) +const point_sea_1 = 25; // Sea #1 (west) +const point_sea_2 = 26; // Sea #2 (east) +const point_coast_1 = 27; // Coast point #1 (E) +const point_coast_2 = 28; // Coast point #2 (W) + + +var QuickSpawn = function(x, z, template, owner = 1) +{ + let ent = Engine.AddEntity(template); + + let cmpEntOwnership = Engine.QueryInterface(ent, IID_Ownership); + if (cmpEntOwnership) + cmpEntOwnership.SetOwner(owner); + + let cmpEntPosition = Engine.QueryInterface(ent, IID_Position); + cmpEntPosition.JumpTo(x, z); + return ent; +}; + +var Rotate = function(angle, ent) +{ + let cmpEntPosition = Engine.QueryInterface(ent, IID_Position); + cmpEntPosition.SetYRotation(angle); + return ent; +}; + +var WalkTo = function(x, z, ent, owner = 1) +{ + ProcessCommand(owner, { + "type": "walk", + "entities": Array.isArray(ent) ? ent : [ent], + "x": x, + "z": z, + "queued": false + }); + return ent; +}; + + +var Do = function(name, data, ent, owner=1) +{ + let comm = { + "type": name, + "entities": Array.isArray(ent) ? ent : [ent], + "queued": false + }; + for (let k in data) + comm[k] = data[k]; + ProcessCommand(owner, comm); +}; + +var cmpTrigger = Engine.QueryInterface(SYSTEM_ENTITY, IID_Trigger); + +Trigger.prototype.UnitsIntoRamClose = function() +{ + let pos = TriggerHelper.GetEntityPosition2D(point_plaza_nw); + let ram = QuickSpawn(pos.x, pos.y, RAM_TEMPLATE, 1); + for (let i = 0; i < 5; ++i) + { + pos = TriggerHelper.GetEntityPosition2D(point_plaza_ne); + Do("garrison", { "target": ram }, QuickSpawn(pos.x+i, pos.y, UNIT_TEMPLATE)); + } +}; + + +Trigger.prototype.UnitsIntoRamFar = function() +{ + let pos = TriggerHelper.GetEntityPosition2D(point_road_5); + let ram = QuickSpawn(pos.x, pos.y, RAM_TEMPLATE, 1); + for (let i = 0; i < 5; ++i) + { + pos = TriggerHelper.GetEntityPosition2D(point_road_1); + Do("garrison", { "target": ram }, QuickSpawn(pos.x+i, pos.y, UNIT_TEMPLATE)); + } +}; + +Trigger.prototype.UnitsIntoRamOppositeDir = function() +{ + let pos = TriggerHelper.GetEntityPosition2D(point_plaza_sw); + let ram = QuickSpawn(pos.x, pos.y, RAM_TEMPLATE, 1); + let spawnpoints = [ + TriggerHelper.GetEntityPosition2D(point_plaza_se), + TriggerHelper.GetEntityPosition2D(point_road_1) + ]; + for (let i = 0; i < 6; ++i) + { + pos = spawnpoints[i%2]; + Do("garrison", { "target": ram }, QuickSpawn(pos.x+i, pos.y, UNIT_TEMPLATE)); + } +}; + + +Trigger.prototype.UnitsIntoRamImpossible = function() +{ + let pos = TriggerHelper.GetEntityPosition2D(point_island_1); + let ram = QuickSpawn(pos.x, pos.y, RAM_TEMPLATE, 1); + for (let i = 0; i < 3; ++i) + { + pos = TriggerHelper.GetEntityPosition2D(point_plaza_se); + Do("garrison", { "target": ram }, QuickSpawn(pos.x+i, pos.y, UNIT_TEMPLATE)); + } +}; + + +Trigger.prototype.UnitsIntoShipClose = function() +{ + let pos = TriggerHelper.GetEntityPosition2D(point_sea_1); + let ship = QuickSpawn(pos.x, pos.y, SHIP_TEMPLATE, 1); + for (let i = 0; i < 6; ++i) + { + pos = TriggerHelper.GetEntityPosition2D(point_plaza_se); + Do("garrison", { "target": ship }, QuickSpawn(pos.x+i, pos.y, UNIT_TEMPLATE)); + } +}; +Trigger.prototype.UnitsIntoShipFar = function() +{ + let pos = TriggerHelper.GetEntityPosition2D(point_sea_1); + let ship = QuickSpawn(pos.x, pos.y, SHIP_TEMPLATE, 1); + for (let i = 0; i < 50; ++i) + { + pos = TriggerHelper.GetEntityPosition2D(point_road_4); + Do("garrison", { "target": ship }, QuickSpawn(pos.x+i, pos.y, UNIT_TEMPLATE)); + } +}; + +Trigger.prototype.UnitsIntoShipTwoIslands = function() +{ + let pos = TriggerHelper.GetEntityPosition2D(point_sea_2); + let ship = QuickSpawn(pos.x, pos.y, SHIP_TEMPLATE, 1); + for (let i = 0; i < 3; ++i) + { + pos = TriggerHelper.GetEntityPosition2D(point_road_2); + Do("garrison", { "target": ship }, QuickSpawn(pos.x+i, pos.y, UNIT_TEMPLATE)); + } + for (let i = 0; i < 3; ++i) + { + pos = TriggerHelper.GetEntityPosition2D(point_island_2); + Do("garrison", { "target": ship }, QuickSpawn(pos.x+i, pos.y, UNIT_TEMPLATE)); + } +}; + + +Trigger.prototype.UnitsIntoShipThenAnotherCloseBy = function() +{ + // This orders a unit to garrison in the ship, then orders another close by (by land) + // but not actually close by (by sea) so that the ship behaves erratically. + let pos = TriggerHelper.GetEntityPosition2D(point_sea_1); + let ship = QuickSpawn(pos.x, pos.y, SHIP_TEMPLATE, 1); + + pos = TriggerHelper.GetEntityPosition2D(point_coast_1); + Do("garrison", { "target": ship }, QuickSpawn(pos.x, pos.y, UNIT_TEMPLATE)); + + // Don't do this at home + let holder = Engine.QueryInterface(ship, IID_GarrisonHolder); + let og = Object.getPrototypeOf(holder).PerformGarrison; + holder.PerformGarrison = (...args) => { + let res = og.apply(holder, args); + delete holder.PerformGarrison; + pos = TriggerHelper.GetEntityPosition2D(point_coast_2); + Do("garrison", { "target": ship }, QuickSpawn(pos.x, pos.y, UNIT_TEMPLATE)); + return res; + }; +}; + + +Trigger.prototype.UnitsIntoShipAlreadyInRange = function() +{ + // Yay ship on land. + let pos = TriggerHelper.GetEntityPosition2D(point_road_3); + let ship = QuickSpawn(pos.x, pos.y, SHIP_TEMPLATE, 1); + for (let i = 0; i < 10; ++i) + { + pos = TriggerHelper.GetEntityPosition2D(point_road_3); + Do("garrison", { "target": ship }, QuickSpawn(pos.x, pos.y, UNIT_TEMPLATE)); + } +}; + + +Trigger.prototype.IslandUnitsPlayerGivesBadOrder = function() +{ + let pos = TriggerHelper.GetEntityPosition2D(point_sea_2); + let ship = QuickSpawn(pos.x, pos.y, SHIP_TEMPLATE, 1); + for (let i = 0; i < 3; ++i) + { + pos = TriggerHelper.GetEntityPosition2D(point_island_1); + Do("garrison", { "target": ship }, QuickSpawn(pos.x+i, pos.y, UNIT_TEMPLATE)); + } + // Order the ship to go somewhere it can't pick up the units from. + WalkTo(567, 53, ship); +}; + +// Call everything here to easily disable/enable only some + +cmpTrigger.DoAfterDelay(200, "UnitsIntoRamClose", {}); +cmpTrigger.DoAfterDelay(200, "UnitsIntoRamFar", {}); +cmpTrigger.DoAfterDelay(200, "UnitsIntoRamOppositeDir", {}); +cmpTrigger.DoAfterDelay(200, "UnitsIntoRamImpossible", {}); +cmpTrigger.DoAfterDelay(200, "UnitsIntoShipClose", {}); +cmpTrigger.DoAfterDelay(200, "UnitsIntoShipFar", {}); +cmpTrigger.DoAfterDelay(200, "UnitsIntoShipTwoIslands", {}); +cmpTrigger.DoAfterDelay(200, "UnitsIntoShipThenAnotherCloseBy", {}); +cmpTrigger.DoAfterDelay(200, "UnitsIntoShipAlreadyInRange", {}); +cmpTrigger.DoAfterDelay(200, "IslandUnitsPlayerGivesBadOrder", {}); Property changes on: ps/trunk/binaries/data/mods/public/maps/scenarios/pickup_test_map_triggers.js ___________________________________________________________________ Added: svn:eol-style ## -0,0 +1 ## +native \ No newline at end of property Index: ps/trunk/binaries/data/mods/public/simulation/components/UnitAI.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/UnitAI.js (revision 23924) +++ ps/trunk/binaries/data/mods/public/simulation/components/UnitAI.js (revision 23925) @@ -1,6510 +1,6500 @@ function UnitAI() {} UnitAI.prototype.Schema = "Controls the unit's movement, attacks, etc, in response to commands from the player." + "" + "" + "" + "violent" + "aggressive" + "defensive" + "passive" + "standground" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "violent" + "aggressive" + "defensive" + "passive" + "skittish" + "domestic" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""+ "" + ""; // 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 // 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, "respondChase": true, "respondChaseBeyondVision": true, "respondStandGround": false, "respondHoldGround": false, "selectable": true }, "aggressive": { "targetVisibleEnemies": true, "targetAttackersAlways": false, "respondFlee": false, "respondChase": true, "respondChaseBeyondVision": false, "respondStandGround": false, "respondHoldGround": false, "selectable": true }, "defensive": { "targetVisibleEnemies": true, "targetAttackersAlways": false, "respondFlee": false, "respondChase": false, "respondChaseBeyondVision": false, "respondStandGround": false, "respondHoldGround": true, "selectable": true }, "passive": { "targetVisibleEnemies": false, "targetAttackersAlways": false, "respondFlee": true, "respondChase": false, "respondChaseBeyondVision": false, "respondStandGround": false, "respondHoldGround": false, "selectable": true }, "standground": { "targetVisibleEnemies": true, "targetAttackersAlways": false, "respondFlee": false, "respondChase": false, "respondChaseBeyondVision": false, "respondStandGround": true, "respondHoldGround": false, "selectable": true }, "none": { // Only to be used by AI or trigger scripts "targetVisibleEnemies": false, "targetAttackersAlways": false, "respondFlee": 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; // 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 }, "HealthChanged": function(msg) { // ignore }, "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) { // ignore when we're not in FORMATIONMEMBER }, // Called when being told to walk as part of a formation "Order.FormationWalk": function(msg) { // Let players move captured domestic animals around if (this.IsAnimal() && !this.IsDomestic() || !this.AbleToMove()) { this.FinishOrder(); return; } // For packable units: // 1. If packed, we can move. // 2. If unpacked, we first need to pack, then follow case 1. if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } this.SetNextState("FORMATIONMEMBER.WALKING"); }, // Special orders: // (these will be overridden by various states) "Order.LeaveFoundation": function(msg) { // If foundation is not ally of entity, or if entity is unpacked siege, // ignore the order if (!this.WillMoveFromFoundation(msg.data.target)) { this.FinishOrder(); return; } this.order.data.min = g_LeaveFoundationRange; this.SetNextState("INDIVIDUAL.WALKING"); }, // Individual orders: // (these will switch the unit out of formation mode) "Order.Stop": function(msg) { // We have no control over non-domestic animals. if (this.IsAnimal() && !this.IsDomestic()) { this.FinishOrder(); return; } // Stop moving immediately. this.StopMoving(); this.FinishOrder(); // No orders left, we're an individual now if (this.IsAnimal()) this.SetNextState("ANIMAL.IDLE"); else if (this.IsFormationMember()) this.SetNextState("FORMATIONMEMBER.IDLE"); else this.SetNextState("INDIVIDUAL.IDLE"); }, "Order.Walk": function(msg) { // Let players move captured domestic animals around if (this.IsAnimal() && !this.IsDomestic() || !this.AbleToMove()) { this.FinishOrder(); return; } // For packable units: // 1. If packed, we can move. // 2. If unpacked, we first need to pack, then follow case 1. if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } this.SetHeldPosition(this.order.data.x, this.order.data.z); // It's not too bad if we don't arrive at exactly the right position. this.order.data.relaxed = true; if (this.IsAnimal()) this.SetNextState("ANIMAL.WALKING"); else this.SetNextState("INDIVIDUAL.WALKING"); }, "Order.WalkAndFight": function(msg) { // Let players move captured domestic animals around if (this.IsAnimal() && !this.IsDomestic() || !this.AbleToMove()) { this.FinishOrder(); return; } // For packable units: // 1. If packed, we can move. // 2. If unpacked, we first need to pack, then follow case 1. if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } this.SetHeldPosition(this.order.data.x, this.order.data.z); // It's not too bad if we don't arrive at exactly the right position. this.order.data.relaxed = true; if (this.IsAnimal()) this.SetNextState("ANIMAL.WALKING"); // WalkAndFight not applicable for animals else this.SetNextState("INDIVIDUAL.WALKINGANDFIGHTING"); }, "Order.WalkToTarget": function(msg) { // Let players move captured domestic animals around if (this.IsAnimal() && !this.IsDomestic() || !this.AbleToMove()) { this.FinishOrder(); return; } // For packable units: // 1. If packed, we can move. // 2. If unpacked, we first need to pack, then follow case 1. if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } if (this.CheckRange(this.order.data)) { // We are already at the target, or can't move at all this.FinishOrder(); return true; } // It's not too bad if we don't arrive at exactly the right position. this.order.data.relaxed = true; if (this.IsAnimal()) this.SetNextState("ANIMAL.WALKING"); else this.SetNextState("INDIVIDUAL.WALKING"); }, "Order.PickupUnit": function(msg) { let cmpGarrisonHolder = Engine.QueryInterface(this.entity, IID_GarrisonHolder); if (!cmpGarrisonHolder || cmpGarrisonHolder.IsFull()) { this.FinishOrder(); return; } + let range = cmpGarrisonHolder.GetLoadingRange(); + this.order.data.min = range.min; + this.order.data.max = range.max; if (this.CheckRange(this.order.data)) { this.FinishOrder(); return; } - // Check if we need to move - // TODO implement a better way to know if we are on the shoreline - let needToMove = true; - let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); - if (this.lastShorelinePosition && cmpPosition && (this.lastShorelinePosition.x == cmpPosition.GetPosition().x) && - (this.lastShorelinePosition.z == cmpPosition.GetPosition().z)) - // we were already on the shoreline, and have not moved since - if (DistanceBetweenEntities(this.entity, this.order.data.target) < 50) - needToMove = false; - - if (needToMove) - this.SetNextState("INDIVIDUAL.PICKUP.APPROACHING"); - else + // 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(this.order.data.target, IID_UnitMotion); + if (cmpPassengerMotion && + cmpPassengerMotion.IsTargetRangeReachable(this.entity, range.min, range.max) && + DistanceBetweenEntities(this.entity, this.order.data.target) < 200) this.SetNextState("INDIVIDUAL.PICKUP.LOADING"); + else + this.SetNextState("INDIVIDUAL.PICKUP.APPROACHING"); }, "Order.Guard": function(msg) { if (!this.AddGuard(this.order.data.target)) { this.FinishOrder(); return; } if (!this.CheckTargetRangeExplicit(this.isGuardOf, 0, this.guardRange)) this.SetNextState("INDIVIDUAL.GUARD.ESCORTING"); else this.SetNextState("INDIVIDUAL.GUARD.GUARDING"); }, "Order.Flee": function(msg) { if (this.IsAnimal()) this.SetNextState("ANIMAL.FLEEING"); else this.SetNextState("INDIVIDUAL.FLEEING"); }, "Order.Attack": function(msg) { // Check the target is alive if (!this.TargetIsAlive(this.order.data.target)) { this.FinishOrder(); return; } // Work out how to attack the given target var type = this.GetBestAttackAgainst(this.order.data.target, this.order.data.allowCapture); if (!type) { // Oops, we can't attack at all this.FinishOrder(); return; } this.order.data.attackType = type; this.RememberTargetPosition(); if (this.order.data.hunting && this.orderQueue.length > 1 && this.orderQueue[1].type === "Gather") this.RememberTargetPosition(this.orderQueue[1].data); // If we are already at the target, try attacking it from here if (this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType)) { // For packable units within attack range: // 1. If unpacked, we can attack the target. // 2. If packed, we first need to unpack, then follow case 1. if (this.CanUnpack()) { this.PushOrderFront("Unpack", { "force": true }); return; } // Cancel any current packing order. if (!this.EnsureCorrectPackStateForAttack(false)) return; if (this.IsAnimal()) this.SetNextState("ANIMAL.COMBAT.ATTACKING"); else this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING"); return; } // If we can't reach the target, but are standing ground, then abandon this attack order. // Unless we're hunting, that's a special case where we should continue attacking our target. if (this.GetStance().respondStandGround && !this.order.data.force && !this.order.data.hunting || !this.AbleToMove()) { this.FinishOrder(); return; } // For packable units out of attack range: // 1. If packed, we need to move to attack range and then unpack. // 2. If unpacked, we first need to pack, then follow case 1. if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } // If we're currently packing/unpacking, make sure we are packed, so we can move. if (!this.EnsureCorrectPackStateForAttack(true)) return; if (this.IsAnimal()) this.SetNextState("ANIMAL.COMBAT.APPROACHING"); else this.SetNextState("INDIVIDUAL.COMBAT.APPROACHING"); }, "Order.Patrol": function(msg) { if (this.IsAnimal() || !this.AbleToMove()) { this.FinishOrder(); return; } if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } // It's not too bad if we don't arrive at exactly the right position. this.order.data.relaxed = true; this.SetNextState("INDIVIDUAL.PATROL"); }, "Order.Heal": function(msg) { // Check the target is alive if (!this.TargetIsAlive(this.order.data.target)) { this.FinishOrder(); return; } // Healers can't heal themselves. if (this.order.data.target == this.entity) { this.FinishOrder(); return; } // Check if the target is in range if (this.CheckTargetRange(this.order.data.target, IID_Heal)) { this.SetNextState("INDIVIDUAL.HEAL.HEALING"); return; } // If we can't reach the target, but are standing ground, // then abandon this heal order if (this.GetStance().respondStandGround && !this.order.data.force) { this.FinishOrder(); return; } this.SetNextState("INDIVIDUAL.HEAL.APPROACHING"); }, "Order.Gather": function(msg) { // If the target is still alive, we need to kill it first if (this.MustKillGatherTarget(this.order.data.target)) { // Make sure we can attack the target, else we'll get very stuck if (!this.GetBestAttackAgainst(this.order.data.target, false)) { // Oops, we can't attack at all - give up // TODO: should do something so the player knows why this failed this.FinishOrder(); return; } // The target was visible when this order was issued, // but could now be invisible again. if (!this.CheckTargetVisible(this.order.data.target)) { if (this.order.data.secondTry === undefined) { this.order.data.secondTry = true; this.PushOrderFront("Walk", this.order.data.lastPos); } // We couldn't move there, or the target moved away else { let data = this.order.data; if (!this.FinishOrder()) this.PushOrderFront("GatherNearPosition", { "x": data.lastPos.x, "z": data.lastPos.z, "type": data.type, "template": data.template }); } return; } this.PushOrderFront("Attack", { "target": this.order.data.target, "force": !!this.order.data.force, "hunting": true, "allowCapture": false }); return; } this.RememberTargetPosition(); if (!this.order.data.initPos) this.order.data.initPos = this.order.data.lastPos; if (this.CheckTargetRange(this.order.data.target, IID_ResourceGatherer)) this.SetNextState("INDIVIDUAL.GATHER.GATHERING"); else this.SetNextState("INDIVIDUAL.GATHER.APPROACHING"); }, "Order.GatherNearPosition": function(msg) { this.SetNextState("INDIVIDUAL.GATHER.WALKING"); this.order.data.initPos = { 'x': this.order.data.x, 'z': this.order.data.z }; this.order.data.relaxed = true; }, "Order.ReturnResource": function(msg) { // Check if the dropsite is already in range let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (this.CheckTargetRange(this.order.data.target, IID_ResourceGatherer) && this.CanReturnResource(this.order.data.target, true, cmpResourceGatherer)) { cmpResourceGatherer.CommitResources(this.order.data.target); // Stop showing the carried resource animation. this.SetDefaultAnimationVariant(); // Our next order should always be a Gather, // so just switch back to that order. this.FinishOrder(); return; } this.SetNextState("INDIVIDUAL.RETURNRESOURCE.APPROACHING"); }, "Order.Trade": function(msg) { // We must check if this trader has both markets in case it was a back-to-work order var cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); if (!cmpTrader || !cmpTrader.HasBothMarkets()) { this.FinishOrder(); return; } // TODO find the nearest way-point from our position, and start with it this.waypoints = undefined; this.SetNextState("TRADE.APPROACHINGMARKET"); }, "Order.Repair": function(msg) { // Try to move within range if (this.CheckTargetRange(this.order.data.target, IID_Builder)) this.SetNextState("INDIVIDUAL.REPAIR.REPAIRING"); else this.SetNextState("INDIVIDUAL.REPAIR.APPROACHING"); }, "Order.Garrison": function(msg) { if (!this.AbleToMove()) { this.SetNextState("IDLE"); return; } else if (this.IsGarrisoned()) { if (this.IsAnimal()) this.SetNextState("ANIMAL.GARRISON.GARRISONED"); else this.SetNextState("INDIVIDUAL.GARRISON.GARRISONED"); return; } // For packable units: // 1. If packed, we can move to the garrison target. // 2. If unpacked, we first need to pack, then follow case 1. if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } if (this.IsAnimal()) this.SetNextState("ANIMAL.GARRISON.APPROACHING"); else this.SetNextState("INDIVIDUAL.GARRISON.APPROACHING"); }, "Order.Ungarrison": function() { this.FinishOrder(); this.isGarrisoned = false; }, "Order.Cheering": function(msg) { if (this.IsFormationMember()) this.SetNextState("FORMATIONMEMBER.CHEERING"); else this.SetNextState("INDIVIDUAL.CHEERING"); }, "Order.Pack": function(msg) { if (this.CanPack()) this.SetNextState("INDIVIDUAL.PACKING"); }, "Order.Unpack": function(msg) { if (this.CanUnpack()) this.SetNextState("INDIVIDUAL.UNPACKING"); }, "Order.CancelPack": function(msg) { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); if (cmpPack && cmpPack.IsPacking() && !cmpPack.IsPacked()) cmpPack.CancelPack(); this.FinishOrder(); }, "Order.CancelUnpack": function(msg) { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); if (cmpPack && cmpPack.IsPacking() && cmpPack.IsPacked()) cmpPack.CancelPack(); this.FinishOrder(); }, // States for the special entity representing a group of units moving in formation: "FORMATIONCONTROLLER": { "Order.Walk": function(msg) { this.CallMemberFunction("SetHeldPosition", [msg.data.x, msg.data.z]); this.SetNextState("WALKING"); }, "Order.WalkAndFight": function(msg) { this.CallMemberFunction("SetHeldPosition", [msg.data.x, msg.data.z]); this.SetNextState("WALKINGANDFIGHTING"); }, "Order.MoveIntoFormation": function(msg) { this.CallMemberFunction("SetHeldPosition", [msg.data.x, msg.data.z]); this.SetNextState("FORMING"); }, // Only used by other orders to walk there in formation "Order.WalkToTargetRange": function(msg) { if (!this.CheckRange(this.order.data)) this.SetNextState("WALKING"); else this.FinishOrder(); }, "Order.WalkToTarget": function(msg) { if (!this.CheckRange(this.order.data)) this.SetNextState("WALKING"); else this.FinishOrder(); }, "Order.WalkToPointRange": function(msg) { if (!this.CheckRange(this.order.data)) this.SetNextState("WALKING"); else this.FinishOrder(); }, "Order.Patrol": function(msg) { this.CallMemberFunction("SetHeldPosition", [msg.data.x, msg.data.z]); this.SetNextState("PATROL"); }, "Order.Guard": function(msg) { this.CallMemberFunction("Guard", [msg.data.target, false]); var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.Disband(); }, "Order.Stop": function(msg) { let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.ResetOrderVariant(); if (!this.IsAttackingAsFormation()) this.CallMemberFunction("Stop", [false]); this.StopMoving(); this.FinishOrder(); // 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(); // Check if we are already in range, otherwise walk there if (!this.CheckFormationTargetAttackRange(target)) { if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { this.SetNextState("COMBAT.APPROACHING"); return; } this.FinishOrder(); return; } 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"); }, "Order.Garrison": function(msg) { if (!Engine.QueryInterface(msg.data.target, IID_GarrisonHolder)) { this.FinishOrder(); return; } // Check if we are already in range, otherwise walk there if (!this.CheckGarrisonRange(msg.data.target)) { if (!this.CheckTargetVisible(msg.data.target)) { this.FinishOrder(); return; } else { this.SetNextState("GARRISON.APPROACHING"); return; } } this.SetNextState("GARRISON.GARRISONING"); }, "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; } this.PushOrderFront("Attack", { "target": msg.data.target, "force": !!msg.data.force, "hunting": true, "allowCapture": false, "min": 0, "max": 10 }); return; } // TODO: on what should we base this range? // Check if we are already in range, otherwise walk there if (!this.CheckTargetRangeExplicit(msg.data.target, 0, 10)) { if (!this.CanGather(msg.data.target) || !this.CheckTargetVisible(msg.data.target)) // The target isn't gatherable or not visible any more. this.FinishOrder(); // TODO: Should we issue a gather-near-position order // if the target isn't gatherable/doesn't exist anymore? else // Out of range; move there in formation this.PushOrderFront("WalkToTargetRange", { "target": msg.data.target, "min": 0, "max": 10 }); return; } this.CallMemberFunction("Gather", [msg.data.target, false]); this.SetNextState("MEMBER"); }, "Order.GatherNearPosition": function(msg) { // TODO: on what should we base this range? // Check if we are already in range, otherwise walk there 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; } this.CallMemberFunction("GatherNearPosition", [msg.data.x, msg.data.z, msg.data.type, msg.data.template, false]); this.SetNextState("MEMBER"); }, "Order.Heal": function(msg) { // TODO: on what should we base this range? // Check if we are already in range, otherwise walk there if (!this.CheckTargetRangeExplicit(msg.data.target, 0, 10)) { if (!this.TargetIsAlive(msg.data.target) || !this.CheckTargetVisible(msg.data.target)) // The target was destroyed this.FinishOrder(); else // Out of range; move there in formation this.PushOrderFront("WalkToTargetRange", { "target": msg.data.target, "min": 0, "max": 10 }); return; } this.CallMemberFunction("Heal", [msg.data.target, false]); this.SetNextState("MEMBER"); }, "Order.Repair": function(msg) { // TODO: on what should we base this range? // Check if we are already in range, otherwise walk there if (!this.CheckTargetRangeExplicit(msg.data.target, 0, 10)) { if (!this.TargetIsAlive(msg.data.target) || !this.CheckTargetVisible(msg.data.target)) // The building was finished or destroyed this.FinishOrder(); else // Out of range move there in formation this.PushOrderFront("WalkToTargetRange", { "target": msg.data.target, "min": 0, "max": 10 }); return; } this.CallMemberFunction("Repair", [msg.data.target, msg.data.autocontinue, false]); this.SetNextState("MEMBER"); }, "Order.ReturnResource": function(msg) { // TODO: on what should we base this range? // Check if we are already in range, otherwise walk there if (!this.CheckTargetRangeExplicit(msg.data.target, 0, 10)) { if (!this.TargetIsAlive(msg.data.target) || !this.CheckTargetVisible(msg.data.target)) // The target was destroyed this.FinishOrder(); else // Out of range; move there in formation this.PushOrderFront("WalkToTargetRange", { "target": msg.data.target, "min": 0, "max": 10 }); return; } this.CallMemberFunction("ReturnResource", [msg.data.target, false]); this.SetNextState("MEMBER"); }, "Order.Pack": function(msg) { this.CallMemberFunction("Pack", [false]); this.SetNextState("MEMBER"); }, "Order.Unpack": function(msg) { this.CallMemberFunction("Unpack", [false]); this.SetNextState("MEMBER"); }, "IDLE": { "enter": function(msg) { return false; }, }, "WALKING": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); return false; }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || this.CheckRange(this.order.data)) this.FinishOrder(); }, }, "WALKINGANDFIGHTING": { "enter": function(msg) { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } this.StartTimer(0, 1000); let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true, "combat"); return false; }, "leave": function() { this.StopMoving(); this.StopTimer(); }, "Timer": function(msg) { // check if there are no enemies to attack this.FindWalkAndFightTargets(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || this.CheckRange(this.order.data)) this.FinishOrder(); }, }, "PATROL": { "enter": function(msg) { // Memorize the origin position in case that we want to go back let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) { this.FinishOrder(); return true; } if (!this.patrolStartPosOrder) { this.patrolStartPosOrder = cmpPosition.GetPosition(); this.patrolStartPosOrder.targetClasses = this.order.data.targetClasses; this.patrolStartPosOrder.allowCapture = this.order.data.allowCapture; } if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } this.StartTimer(0, 1000); let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true, "combat"); return false; }, "Timer": function(msg) { // Check if there are no enemies to attack this.FindWalkAndFightTargets(); }, "leave": function(msg) { this.StopTimer(); this.StopMoving(); delete this.patrolStartPosOrder; }, "MovementUpdate": function(msg) { if (!msg.likelyFailure && !this.CheckRange(this.order.data)) return; /** * A-B-A-B-..: * if the user only commands one patrol order, the patrol will be between * the last position and the defined waypoint * A-B-C-..-A-B-..: * otherwise, the patrol is only between the given patrol commands and the * last position is not included (last position = the position where the unit * is located at the time of the first patrol order) */ if (this.orderQueue.length == 1) this.PushOrder("Patrol", this.patrolStartPosOrder); this.PushOrder(this.order.type, this.order.data); this.FinishOrder(); }, }, "GARRISON":{ "APPROACHING": { "enter": function() { if (!this.MoveToGarrisonRange(this.order.data.target)) { this.FinishOrder(); return true; } let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); // If the garrisonholder should pickup, warn it so it can take needed action. let cmpGarrisonHolder = Engine.QueryInterface(this.order.data.target, IID_GarrisonHolder); if (cmpGarrisonHolder && cmpGarrisonHolder.CanPickup(this.entity)) { this.pickup = this.order.data.target; // temporary, deleted in "leave" Engine.PostMessage(this.pickup, MT_PickupRequested, { "entity": this.entity }); } return false; }, "leave": function() { this.StopMoving(); // If a pickup has been requested and not yet canceled, cancel it. 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("Garrison", [this.order.data.target, false]); this.SetNextState("MEMBER"); return true; }, }, }, "FORMING": { "enter": function() { if (!this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, 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() { if (!this.MoveFormationToTargetAttackRange(this.order.data.target)) { this.FinishOrder(); return true; } let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true, "combat"); 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; // Check if we are already in range, otherwise walk there if (!this.CheckFormationTargetAttackRange(target)) { if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { this.FinishOrder(); this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); 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; // Check if we are already in range, otherwise walk there if (!this.CheckFormationTargetAttackRange(target)) { if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { this.FinishOrder(); this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": allowCapture }); 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 hwoever because we wait for all // entities to ahve 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.MoveOutOfWorld(); this.StartTimer(1000, 1000); return false; }, "Timer": function(msg) { // Have all members finished the task? let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (cmpFormation && !cmpFormation.AreAllMembersWaiting()) return; // Execute the next order if (this.FinishOrder()) { // if WalkAndFight order, look for new target before moving again 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.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; } // No orders left, we're an individual now 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 foundation is not ally of entity, or if entity is unpacked siege, // ignore the order if (!this.WillMoveFromFoundation(msg.data.target)) { this.FinishOrder(); return; } this.order.data.min = g_LeaveFoundationRange; this.SetNextState("WALKINGTOPOINT"); }, "enter": function() { if (this.IsAnimal()) { // Animals can't go in formation. warn("Entity " + this.entity + " was put in FORMATIONMEMBER state but is an animal"); this.FinishOrder(); this.SetNextState("ANIMAL.IDLE"); return true; } 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(); // Reset previous behaviour. 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; }, "MovementUpdate": function() { if (!this.CheckRange(this.order.data)) return; this.StopMoving(); this.FinishOrder(); }, }, }, // States for entities not part of a formation: "INDIVIDUAL": { "enter": function() { // Sanity-checking if (this.IsAnimal()) error("Animal got moved into INDIVIDUAL.* state"); return false; }, "Attacked": function(msg) { // Respond to attack if we always target attackers or during unforced orders 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.TargetIsAlive(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; } // if the attacker is a building and we can repair the guarded, repair it rather than attacking 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; } // target the unit 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": { "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) { this.isIdle = false; Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle }); } }, // 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 the unit is guarding/escorting, go back to its duty. 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; this.isIdle = true; Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle }); } }, }, "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 (3 tiles) // 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 (3 tiles) // 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() { // Memorize the origin position in case that we want to go back let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld() || !this.MoveTo(this.order.data)) { this.FinishOrder(); return true; } if (!this.patrolStartPosOrder) { this.patrolStartPosOrder = cmpPosition.GetPosition(); this.patrolStartPosOrder.targetClasses = this.order.data.targetClasses; this.patrolStartPosOrder.allowCapture = this.order.data.allowCapture; } this.StartTimer(0, 1000); this.SetAnimationVariant("combat"); return false; }, "leave": function() { this.StopMoving(); this.StopTimer(); delete this.patrolStartPosOrder; this.SetDefaultAnimationVariant(); }, "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.FinishOrder(); }, }, "GUARD": { "RemoveGuard": function() { this.StopMoving(); 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) { // Check the target is alive if (!this.TargetIsAlive(this.isGuardOf)) { this.FinishOrder(); return; } // Adapt the speed to the one of the target if needed 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.StopMoving(); 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) { // Check the target is alive if (!this.TargetIsAlive(this.isGuardOf)) { this.FinishOrder(); return; } // Then check is the target has moved and try following it. // 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); // if nothing better to do, check if the guarded needs to be healed or repaired 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 = 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"); // Run quickly 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(); }, "HealthChanged": function() { this.SetSpeedMultiplier(this.GetRunMultiplier()); }, "leave": function() { this.ResetSpeedMultiplier(); this.StopMoving(); }, "MovementUpdate": function(msg) { // When we've run far enough, stop fleeing if (msg.likelyFailure || this.CheckTargetRangeExplicit(this.order.data.target, this.order.data.distanceToFlee, -1)) this.FinishOrder(); }, // TODO: what if we run into more enemies while fleeing? }, "COMBAT": { "Order.LeaveFoundation": function(msg) { // Ignore the order as we're busy. return { "discardOrder": true }; }, "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.StopMoving(); this.FinishOrder(); // Return to our original position 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; } // Go to the last known position and try to find enemies there. let lastPos = this.order.data.lastPos; this.PushOrder("WalkAndFight", { "x": lastPos.x, "z": lastPos.z, "force": false }); return; } if (this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType)) { // If the unit needs to unpack, do so 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 the target is a formation, save the attacking formation, and pick a member if (cmpFormation) { this.order.data.formationTarget = target; target = cmpFormation.GetClosestMember(this.entity); this.order.data.target = target; } if (!this.CanAttack(target)) { this.SetNextState("COMBAT.FINDINGNEWTARGET"); return true; } if (!this.CheckTargetAttackRange(target, this.order.data.attackType)) { if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return true; } this.SetNextState("COMBAT.APPROACHING"); return true; } this.StopMoving(); let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); this.attackTimers = cmpAttack.GetTimers(this.order.data.attackType); // If the repeat time since the last attack hasn't elapsed, // delay this attack to avoid attacking too fast. let prepare = this.attackTimers.prepare; if (this.lastAttacked) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); let repeatLeft = this.lastAttacked + this.attackTimers.repeat - cmpTimer.GetTime(); prepare = Math.max(prepare, repeatLeft); } if (!this.formationAnimationVariant) this.SetAnimationVariant("combat"); this.oldAttackType = this.order.data.attackType; // add prefix + no capital first letter for attackType this.SelectAnimation("attack_" + this.order.data.attackType.toLowerCase()); this.SetAnimationSync(prepare, this.attackTimers.repeat); this.StartTimer(prepare, this.attackTimers.repeat); // TODO: we should probably only bother syncing projectile attacks, not melee // If using a non-default prepare time, re-sync the animation when the timer runs. this.resyncAnimation = prepare != this.attackTimers.prepare; this.FaceTowardsTarget(this.order.data.target); let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); if (cmpBuildingAI) cmpBuildingAI.SetUnitAITarget(this.order.data.target); return false; }, "leave": function() { let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); if (cmpBuildingAI) cmpBuildingAI.SetUnitAITarget(0); this.StopTimer(); this.ResetAnimation(); }, "Timer": function(msg) { let target = this.order.data.target; let attackType = this.order.data.attackType; // Check the target is still alive and attackable if (!this.CanAttack(target)) { this.SetNextState("COMBAT.FINDINGNEWTARGET"); return; } this.RememberTargetPosition(); if (this.order.data.hunting && this.orderQueue.length > 1 && this.orderQueue[1].type === "Gather") this.RememberTargetPosition(this.orderQueue[1].data); let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); this.lastAttacked = cmpTimer.GetTime() - msg.lateness; this.FaceTowardsTarget(target); // BuildingAI has it's own attack-routine let cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); if (!cmpBuildingAI) { let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); cmpAttack.PerformAttack(attackType, target); } // PerformAttack might have triggered messages that moved us to another state. // (use 'ends with' to handle animals/formation members copying our state). if (!this.GetCurrentState().endsWith("COMBAT.ATTACKING")) return; // Check we can still reach the target for the next attack if (this.CheckTargetAttackRange(target, attackType)) { if (this.resyncAnimation) { this.SetAnimationSync(this.attackTimers.repeat, this.attackTimers.repeat); this.resyncAnimation = false; } return; } // Can't reach it - try to chase after it if (this.ShouldChaseTargetedEntity(target, this.order.data.force)) { if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } this.SetNextState("COMBAT.CHASING"); return; } this.SetNextState("FINDINGNEWTARGET"); }, // TODO: respond to target deaths immediately, rather than waiting // until the next Timer event "Attacked": function(msg) { // If we are capturing and are attacked by something that we would not capture, attack that entity instead 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": { "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()) this.FindWalkAndFightTargets(); return true; } // See if we can switch to a new nearby enemy if (this.FindNewTargets()) return true; // Return to our original position if (this.GetStance().respondHoldGround) this.WalkToHeldPosition(); return true; }, }, "CHASING": { "Order.MoveToChasingPoint": function(msg) { if (this.CheckPointRangeExplicit(msg.data.x, msg.data.z, 0, msg.data.max)) { this.StopMoving(); this.FinishOrder(); return; } this.order.data.relaxed = true; this.StopTimer(); this.SetNextState("MOVINGTOPOINT"); }, "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.StopMoving(); this.FinishOrder(); // Return to our original position 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 the unit needs to unpack, do so 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 (3 tiles) 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": { "leave": function() { // Show the carried resource, if we've gathered anything. this.SetDefaultAnimationVariant(); }, "APPROACHING": { "enter": function() { this.gatheringTarget = this.order.data.target; // temporary, deleted in "leave". // Check that we can gather from the resource we're supposed to gather from. let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); 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(cmpOwnership.GetOwner(), 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; } 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; // don't use ownership because this is called after a conversion/resignation // and the ownership would be invalid then. 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 we failed, the GATHERING timer will handle finding a valid resource. if (msg.likelyFailure || msg.obstructed && this.RelaxedMaxRangeCheck(this.order.data, this.DefaultRelaxedMaxRange) || this.CheckRange(this.order.data)) this.SetNextState("GATHERING"); }, }, "GATHERING": { "enter": function() { this.gatheringTarget = this.order.data.target || INVALID_ENTITY; // deleted in "leave". // Check if the resource is full. // Will only be added if we're not already in. let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); let cmpSupply; if (cmpOwnership) cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); if (!cmpSupply || !cmpSupply.AddGatherer(cmpOwnership.GetOwner(), this.entity)) { this.SetNextState("FINDINGNEWTARGET"); 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; // Calculate timing based on gather rates // This allows the gather rate to control how often we gather, instead of how much. let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); let rate = cmpResourceGatherer.GetTargetGatherRate(this.gatheringTarget); if (!rate) { // Try to find another target if the current one stopped existing if (!Engine.QueryInterface(this.gatheringTarget, IID_Identity)) { this.SetNextState("FINDINGNEWTARGET"); return true; } // No rate, give up on gathering this.FinishOrder(); return true; } // Scale timing interval based on rate, and start timer // The offset should be at least as long as the repeat time so we use the same value for both. let offset = 1000 / rate; this.StartTimer(offset, offset); // We want to start the gather animation as soon as possible, // but only if we're actually at the target and it's still alive // (else it'll look like we're chopping empty air). // (If it's not alive, the Timer handler will deal with sending us // off to a different target.) if (this.CheckTargetRange(this.gatheringTarget, IID_ResourceGatherer)) { this.StopMoving(); this.SetDefaultAnimationVariant(); this.FaceTowardsTarget(this.order.data.target); this.SelectAnimation("gather_" + this.order.data.type.specific); } return false; }, "leave": function() { this.StopTimer(); // Don't use ownership because this is called after a conversion/resignation // and the ownership would be invalid then. let cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); if (cmpSupply) cmpSupply.RemoveGatherer(this.entity); delete this.gatheringTarget; this.ResetAnimation(); }, "Timer": function(msg) { let resourceTemplate = this.order.data.template; let resourceType = this.order.data.type; let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership) return; // TODO: we are leaking information here - if the target died in FOW, we'll know it's dead // straight away. // Seems one would have to listen to ownership changed messages to make it work correctly // but that's likely prohibitively expansive performance wise. let cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); // If we can't gather from the target, find a new one. if (!cmpSupply || !cmpSupply.IsAvailable(cmpOwnership.GetOwner(), this.entity) || !this.CanGather(this.gatheringTarget)) { this.SetNextState("FINDINGNEWTARGET"); return; } if (!this.CheckTargetRange(this.gatheringTarget, IID_ResourceGatherer)) { // Try to follow the target if (this.MoveToTargetRange(this.gatheringTarget, 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.gatheringTarget) && 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"); return; } // Gather the resources: let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); // Try to gather treasure if (cmpResourceGatherer.TryInstantGather(this.gatheringTarget)) return; // If we've already got some resources but they're the wrong type, // drop them first to ensure we're only ever carrying one type if (cmpResourceGatherer.IsCarryingAnythingExcept(resourceType.generic)) cmpResourceGatherer.DropResources(); this.FaceTowardsTarget(this.order.data.target); // Collect from the target let status = cmpResourceGatherer.PerformGather(this.gatheringTarget); // If we've collected as many resources as possible, // return to the nearest dropsite if (status.filled) { let nearestDropsite = this.FindNearestDropsite(resourceType.generic); if (nearestDropsite) { // (Keep this Gather order on the stack so we'll // continue gathering after returning) // However mark our target as invalid if it's exhausted, so we don't waste time // trying to gather from it. if (status.exhausted) this.order.data.target = INVALID_ENTITY; this.PushOrderFront("ReturnResource", { "target": nearestDropsite, "force": false }); return; } // Oh no, couldn't find any drop sites. Give up on gathering. this.FinishOrder(); return; } // Find a new target if the current one is exhausted if (status.exhausted) this.SetNextState("FINDINGNEWTARGET"); }, }, "FINDINGNEWTARGET": { "enter": function() { 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 // Give up if we're not in the world right now. let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return true; // If we have no known initial position of our target, look around our own position // as a fallback. if (!initPos) { let pos = cmpPosition.GetPosition(); initPos = { 'x': pos.X, 'z': pos.Z }; } // Try to find a new resource of the same specific type near the initial resource position: // Also don't switch to a different type of huntable animal let nearbyResource = this.FindNearbyResource(new Vector2D(initPos.x, initPos.z), (ent, type, template) => { if (previousTarget == ent) return false; if (type.generic == "treasure" && resourceType.generic == "treasure") return true; return type.specific == resourceType.specific && (type.specific != "meat" || resourceTemplate == template); }); 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?) if (!this.CheckPointRangeExplicit(initPos.x, initPos.z, 0, 10)) { 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; }, }, }, "HEAL": { "Attacked": function(msg) { // If we stand ground we will rather die than flee 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.SetNextState("FINDINGNEWTARGET"); 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() { if (!this.CheckRange(this.order.data, IID_Heal)) { this.SetNextState("APPROACHING"); return true; } if (!this.TargetIsAlive(this.order.data.target) || !this.CanHeal(this.order.data.target)) { this.SetNextState("FINDINGNEWTARGET"); return true; } let cmpHeal = Engine.QueryInterface(this.entity, IID_Heal); this.healTimers = cmpHeal.GetTimers(); // If the repeat time since the last heal hasn't elapsed, // delay the action to avoid healing too fast. var prepare = this.healTimers.prepare; if (this.lastHealed) { var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); var repeatLeft = this.lastHealed + this.healTimers.repeat - cmpTimer.GetTime(); prepare = Math.max(prepare, repeatLeft); } this.SelectAnimation("heal"); this.SetAnimationSync(prepare, this.healTimers.repeat); this.StartTimer(prepare, this.healTimers.repeat); // If using a non-default prepare time, re-sync the animation when the timer runs. this.resyncAnimation = prepare != this.healTimers.prepare; this.FaceTowardsTarget(this.order.data.target); return false; }, "leave": function() { this.ResetAnimation(); this.StopTimer(); }, "Timer": function(msg) { let target = this.order.data.target; // Check the target is still alive and healable if (!this.TargetIsAlive(target) || !this.CanHeal(target)) { this.SetNextState("FINDINGNEWTARGET"); return; } // Check if we can still reach the target if (!this.CheckRange(this.order.data, IID_Heal)) { if (this.ShouldChaseTargetedEntity(target, this.order.data.force)) { // Can't reach it - try to chase after it if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } this.SetNextState("HEAL.APPROACHING"); } else this.SetNextState("FINDINGNEWTARGET"); return; } let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); this.lastHealed = cmpTimer.GetTime() - msg.lateness; this.FaceTowardsTarget(target); let cmpHeal = Engine.QueryInterface(this.entity, IID_Heal); cmpHeal.PerformHeal(target); if (this.resyncAnimation) { this.SetAnimationSync(this.healTimers.repeat, this.healTimers.repeat); this.resyncAnimation = false; } }, }, "FINDINGNEWTARGET": { "enter": function() { // If we have another order, do that instead. if (this.FinishOrder()) return true; // Heal another one if (this.FindNewHealTargets()) return true; // Return to our original position 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) { // Check the dropsite is in range and we can return our resource there // (we didn't get stopped before reaching it) let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (this.CheckTargetRange(this.order.data.target, IID_ResourceGatherer) && this.CanReturnResource(this.order.data.target, true, cmpResourceGatherer)) { cmpResourceGatherer.CommitResources(this.order.data.target); // Stop showing the carried resource animation. this.SetDefaultAnimationVariant(); // Our next order should always be a Gather, // so just switch back to that order. this.FinishOrder(); return; } if (msg.obstructed) return; // If we are here: we are in range but not carrying the right resources (or resources at all), // the dropsite was destroyed, or we couldn't reach it, or ownership changed. // Look for a new one. let genericType = cmpResourceGatherer.GetMainCarryingType(); let nearby = this.FindNearestDropsite(genericType); if (nearby) { this.FinishOrder(); this.PushOrderFront("ReturnResource", { "target": nearby, "force": false }); return; } // Oh no, couldn't find any drop sites. Give up on returning. this.FinishOrder(); }, }, }, "TRADE": { "Attacked": function(msg) { // Ignore attack // TODO: Inform player }, "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.CheckTargetRange(this.order.data.target, IID_Trader)) return; if (this.waypoints && this.waypoints.length) { if (!this.MoveToMarket(this.order.data.target)) this.StopTrading(); } else this.PerformTradeAndMoveToNextMarket(this.order.data.target); }, }, "TradingCanceled": function(msg) { if (msg.market != this.order.data.target) return; let cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); let otherMarket = cmpTrader && cmpTrader.GetFirstMarket(); this.StopTrading(); if (otherMarket) this.WalkToTarget(otherMarket); }, }, "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() { // 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; // Needed to remove the entity from the builder list when leaving this state. this.repairTarget = this.order.data.target; // Check we can still reach and repair the target. if (!this.CanRepair(this.repairTarget)) { // Can't reach it, no longer owned by ally, or it doesn't exist any more. this.FinishOrder(); return true; } if (!this.CheckTargetRange(this.repairTarget, IID_Builder)) { this.SetNextState("APPROACHING"); return true; } // Check if the target is still repairable. let cmpHealth = Engine.QueryInterface(this.repairTarget, 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.repairTarget, "newentity": this.repairTarget }); return true; } this.StopMoving(); let cmpBuilderList = QueryBuilderListInterface(this.repairTarget); if (cmpBuilderList) cmpBuilderList.AddBuilder(this.entity); this.FaceTowardsTarget(this.repairTarget); this.SelectAnimation("build"); this.StartTimer(1000, 1000); return false; }, "leave": function() { let cmpBuilderList = QueryBuilderListInterface(this.repairTarget); if (cmpBuilderList) cmpBuilderList.RemoveBuilder(this.entity); delete this.repairTarget; this.StopTimer(); this.ResetAnimation(); }, "Timer": function(msg) { // Check we can still reach and repair the target. if (!this.CanRepair(this.repairTarget)) { // No longer owned by ally, or it doesn't exist any more. this.FinishOrder(); return; } this.FaceTowardsTarget(this.repairTarget); let cmpBuilder = Engine.QueryInterface(this.entity, IID_Builder); cmpBuilder.PerformBuilding(this.repairTarget); // If the building is completed, the leave() function will be called // by the ConstructionFinished message. // In that case, the repairTarget is deleted, and we can just return. if (!this.repairTarget) return; if (!this.CheckTargetRange(this.repairTarget, IID_Builder)) this.SetNextState("APPROACHING"); }, }, "ConstructionFinished": function(msg) { if (msg.data.entity != this.order.data.target) return; // ignore other buildings // Save the current order's data in case we need it later 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(); // Drop any resource we can if we are in range when the construction finishes 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(); } // We finished building it. // 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; } } // Look for a nearby foundation to help with. 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": { "leave": function() { // If a pickup has been requested and not yet canceled, cancel it. if (this.pickup) { Engine.PostMessage(this.pickup, MT_PickupCanceled, { "entity": this.entity }); delete this.pickup; } return false; }, "APPROACHING": { "enter": function() { if (!this.MoveToGarrisonRange(this.order.data.target)) { this.FinishOrder(); return true; } // If a pickup was still pending, cancel that. if (this.pickup) Engine.PostMessage(this.pickup, MT_PickupCanceled, { "entity": this.entity }); // If the garrisonholder should pickup, warn it so it can take needed action. let cmpGarrisonHolder = Engine.QueryInterface(this.order.data.target, IID_GarrisonHolder); if (cmpGarrisonHolder && cmpGarrisonHolder.CanPickup(this.entity)) { this.pickup = this.order.data.target; // temporary, deleted in "leave" Engine.PostMessage(this.pickup, MT_PickupRequested, { "entity": this.entity }); } return false; }, "leave": function() { this.StopMoving(); }, "MovementUpdate": function(msg) { if (msg.likelyFailure || msg.likelySuccess) this.SetNextState("GARRISONED"); }, }, "GARRISONED": { "enter": function() { let target = this.order.data.target; if (!target) { this.FinishOrder(); return true; } if (this.IsGarrisoned()) return false; // Check that we can garrison here if (this.CanGarrison(target)) // Check that we're in range of the garrison target if (this.CheckGarrisonRange(target)) { var cmpGarrisonHolder = Engine.QueryInterface(target, IID_GarrisonHolder); // Check that garrisoning succeeds if (cmpGarrisonHolder.Garrison(this.entity)) { this.isGarrisoned = true; this.SetImmobile(true); if (this.formationController) { var cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) { // disable rearrange for this removal, // but enable it again for the next // move command var rearrange = cmpFormation.rearrange; cmpFormation.SetRearrange(false); cmpFormation.RemoveMembers([this.entity]); cmpFormation.SetRearrange(rearrange); } } // Check if we are garrisoned in a dropsite. let cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (this.CanReturnResource(target, true, cmpResourceGatherer)) { cmpResourceGatherer.CommitResources(target); this.SetDefaultAnimationVariant(); } // If a pickup has been requested, remove it if (this.pickup) { - var cmpHolderPosition = Engine.QueryInterface(target, IID_Position); - var cmpHolderUnitAI = Engine.QueryInterface(target, IID_UnitAI); - if (cmpHolderUnitAI && cmpHolderPosition) - cmpHolderUnitAI.lastShorelinePosition = cmpHolderPosition.GetPosition(); Engine.PostMessage(this.pickup, MT_PickupCanceled, { "entity": this.entity }); delete this.pickup; } if (this.IsTurret()) { this.SetNextState("IDLE"); return true; } return false; } } else { // Unable to reach the target, try again (or follow if it is a moving target) // except if the does not exits anymore or its orders have changed if (this.pickup) { var cmpUnitAI = Engine.QueryInterface(this.pickup, IID_UnitAI); - if (!cmpUnitAI || !cmpUnitAI.HasPickupOrder(this.entity)) + if (!cmpUnitAI || (!cmpUnitAI.HasPickupOrder(this.entity) && + !cmpUnitAI.IsIdle())) { this.FinishOrder(); return true; } } this.SetNextState("APPROACHING"); return true; } // Garrisoning failed for some reason, so finish the order this.FinishOrder(); return true; }, "leave": function() { } }, }, "CHEERING": { "enter": function() { // Unit is invulnerable while cheering var cmpResistance = Engine.QueryInterface(this.entity, IID_Resistance); cmpResistance.SetInvulnerability(true); this.SelectAnimation("promotion"); this.StartTimer(2800, 2800); return false; }, "leave": function() { this.StopTimer(); this.ResetAnimation(); if (this.formationAnimationVariant) this.SetAnimationVariant(this.formationAnimationVariant) else this.SetDefaultAnimationVariant(); var cmpResistance = Engine.QueryInterface(this.entity, IID_Resistance); cmpResistance.SetInvulnerability(false); }, "Timer": function(msg) { this.FinishOrder(); }, }, "PACKING": { "enter": function() { this.StopMoving(); var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); cmpPack.Pack(); return false; }, "PackFinished": function(msg) { this.FinishOrder(); }, "leave": function() { }, "Attacked": function(msg) { // Ignore attacks while packing }, }, "UNPACKING": { "enter": function() { this.StopMoving(); var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); cmpPack.Unpack(); return false; }, "PackFinished": function(msg) { this.FinishOrder(); }, "leave": function() { }, "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.StopMoving(); this.FinishOrder(); }, }, "LOADING": { "enter": function() { this.StopMoving(); var cmpGarrisonHolder = Engine.QueryInterface(this.entity, IID_GarrisonHolder); if (!cmpGarrisonHolder || cmpGarrisonHolder.IsFull()) { this.FinishOrder(); return true; } return false; }, "PickupCanceled": function() { this.FinishOrder(); }, }, }, }, "ANIMAL": { "Attacked": function(msg) { if (this.template.NaturalBehaviour == "skittish" || this.template.NaturalBehaviour == "passive") { this.Flee(msg.data.attacker, false); } else if (this.IsDangerousAnimal() || this.template.NaturalBehaviour == "defensive") { if (this.CanAttack(msg.data.attacker)) this.Attack(msg.data.attacker, false); } else if (this.template.NaturalBehaviour == "domestic") { // Never flee, stop what we were doing this.SetNextState("IDLE"); } }, "Order.LeaveFoundation": function(msg) { // Move a tile outside the building if (this.CheckTargetRangeExplicit(msg.data.target, g_LeaveFoundationRange, -1)) { this.FinishOrder(); return; } this.order.data.min = g_LeaveFoundationRange; this.SetNextState("WALKING"); }, "IDLE": { // (We need an IDLE state so that FinishOrder works) "enter": function() { // Start feeding immediately this.SetNextState("FEEDING"); return true; }, }, "ROAMING": { "enter": function() { // Walk in a random direction this.SetFacePointAfterMove(false); this.MoveRandomly(+this.template.RoamDistance); // Set a random timer to switch to feeding state this.StartTimer(randIntInclusive(+this.template.RoamTimeMin, +this.template.RoamTimeMax)); return false; }, "leave": function() { this.StopMoving(); this.StopTimer(); this.SetFacePointAfterMove(true); }, "LosRangeUpdate": function(msg) { if (msg && msg.data && msg.data.added && msg.data.added.length) this.RespondToSightedEntities(msg.data.added); }, "LosAttackRangeUpdate": function(msg) { if (this.IsDangerousAnimal() && msg && msg.data && msg.data.added && msg.data.added.length) this.AttackVisibleEntity(msg.data.added); // TODO: if two units enter our range together, we'll attack the // first and then the second won't trigger another LosAttackRangeUpdate // so we won't notice it. Probably we should do something with // ResetActiveQuery in ROAMING.enter/FEEDING.enter in order to // find any units that are already in range. }, "Timer": function(msg) { this.SetNextState("FEEDING"); }, "MovementUpdate": function() { this.MoveRandomly(+this.template.RoamDistance); }, }, "FEEDING": { "enter": function() { // Stop and eat for a while this.SelectAnimation("feeding"); this.StopMoving(); this.StartTimer(randIntInclusive(+this.template.FeedTimeMin, +this.template.FeedTimeMax)); return false; }, "leave": function() { this.ResetAnimation(); this.StopTimer(); }, "LosRangeUpdate": function(msg) { if (msg && msg.data && msg.data.added && msg.data.added.length) this.RespondToSightedEntities(msg.data.added); }, "LosAttackRangeUpdate": function(msg) { if (this.template.NaturalBehaviour == "violent" && msg && msg.data && msg.data.added && msg.data.added.length) this.AttackVisibleEntity(msg.data.added); }, "Timer": function(msg) { this.SetNextState("ROAMING"); }, }, "FLEEING": "INDIVIDUAL.FLEEING", // reuse the same fleeing behaviour for animals "COMBAT": "INDIVIDUAL.COMBAT", // reuse the same combat behaviour for animals "WALKING": "INDIVIDUAL.WALKING", // reuse the same walking behaviour for animals // only used for domestic animals // Reuse the same garrison behaviour for animals. "GARRISON": "INDIVIDUAL.GARRISON", }, }; 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.isGarrisoned = false; this.isIdle = false; this.isImmobile = false; // True if the unit is currently unable to move (garrisoned,...) this.heldPosition = undefined; // Queue of remembered works this.workOrders = []; this.isGuardOf = undefined; // For preventing increased action rate due to Stop orders or target death. this.lastAttacked = undefined; this.lastHealed = undefined; this.formationAnimationVariant = undefined; this.SetStance(this.template.DefaultStance); }; UnitAI.prototype.IsTurret = function() { let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); return cmpPosition && cmpPosition.GetTurretParent() != INVALID_ENTITY; }; UnitAI.prototype.IsFormationController = function() { return (this.template.FormationController == "true"); }; UnitAI.prototype.IsFormationMember = function() { return (this.formationController != INVALID_ENTITY); }; UnitAI.prototype.IsAnimal = function() { return (this.template.NaturalBehaviour ? true : false); }; UnitAI.prototype.IsDangerousAnimal = function() { return (this.IsAnimal() && (this.template.NaturalBehaviour == "violent" || this.template.NaturalBehaviour == "aggressive")); }; UnitAI.prototype.IsDomestic = function() { var cmpIdentity = Engine.QueryInterface(this.entity, IID_Identity); return cmpIdentity && cmpIdentity.HasClass("Domestic"); }; UnitAI.prototype.IsHealer = function() { return Engine.QueryInterface(this.entity, IID_Heal); }; UnitAI.prototype.IsIdle = function() { return this.isIdle; }; UnitAI.prototype.IsGarrisoned = function() { return this.isGarrisoned; }; UnitAI.prototype.SetGarrisoned = function() { this.isGarrisoned = true; }; UnitAI.prototype.GetGarrisonHolder = function() { if (this.IsGarrisoned()) { for (let order of this.orderQueue) if (order.type == "Garrison") return order.data.target; } return INVALID_ENTITY; }; UnitAI.prototype.ShouldRespondToEndOfAlert = function() { return !this.orderQueue.length || this.orderQueue[0].type == "Garrison"; }; UnitAI.prototype.SetImmobile = function(immobile) { this.isImmobile = immobile; 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 || this.IsTurret()) 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.IsAnimal()) this.UnitFsm.Init(this, "ANIMAL.FEEDING"); else 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 garrisoned or cheering or (un)packing, in which case we only clear the order queue if (this.isGarrisoned || this.IsPacking() || this.orderQueue[0] && this.orderQueue[0].type == "Cheering") { this.orderQueue.length = Math.min(this.orderQueue.length, 1); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); } else { let index = this.GetCurrentState().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) { // Update range queries 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) { // First check if we already have such a request if (this.HasPickupOrder(msg.entity)) return; // Otherwise, insert the PickUp order after the last forced order this.PushOrderAfterForced("PickupUnit", { "target": msg.entity }); }; 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() { // Only skittish animals use this for now. if (this.template.NaturalBehaviour && this.template.NaturalBehaviour == "skittish") 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); this.losRangeQuery = cmpRangeManager.CreateActiveQuery(this.entity, range.min, range.max, players, IID_Identity, cmpRangeManager.GetEntityFlagMask("normal")); 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); this.losHealRangeQuery = cmpRangeManager.CreateActiveQuery(this.entity, range.min, range.max, players, IID_Health, cmpRangeManager.GetEntityFlagMask("injured")); 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); this.losAttackRangeQuery = cmpRangeManager.CreateActiveQuery(this.entity, range.min, range.max, players, IID_Resistance, cmpRangeManager.GetEntityFlagMask("normal")); 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. UnitAI.prototype.SetNextState = function(state) { this.UnitFsm.SetNextState(this, state); }; 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). */ 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]; let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (this.orderQueue.length && (this.IsGarrisoned() || this.IsFormationController() || cmpPosition && cmpPosition.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() }); // If the order was rejected then immediately take it off // and process the remaining queue if (ret && ret.discardOrder) return this.FinishOrder(); // Otherwise we've successfully processed a new order return true; } 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 let cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); cmpFormation.SetWaitingOnController(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 we didn't already have an order, then process this new one if (this.orderQueue.length == 1) { this.order = order; let ret = this.UnitFsm.ProcessMessage(this, { "type": "Order."+this.order.type, "data": this.order.data } ); // If the order was rejected then immediately take it off // and process the remaining queue if (ret && ret.discardOrder) this.FinishOrder(); } 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 cheering then add new order after it // same thing if current order if packing/unpacking if (this.order && this.order.type == "Cheering") { var cheeringOrder = this.orderQueue.shift(); this.orderQueue.unshift(cheeringOrder, order); } else if (!ignorePacking && this.order && this.IsPacking()) { var packingOrder = this.orderQueue.shift(); this.orderQueue.unshift(packingOrder, order); } else { this.orderQueue.unshift(order); this.order = order; let ret = this.UnitFsm.ProcessMessage(this, { "type": "Order."+this.order.type, "data": this.order.data } ); // If the order was rejected then immediately take it off again; // assume the previous active order is still valid (the short-lived // new order hasn't changed state or anything) so we can carry on // as if nothing had happened if (ret && ret.discardOrder) { this.orderQueue.shift(); this.order = this.orderQueue[0]; } } 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) { // If foundation is not ally of entity, or if entity is unpacked siege, // ignore the order. if (!IsOwnedByAllyOfEntity(this.entity, target) && !Engine.QueryInterface(SYSTEM_ENTITY, IID_CeasefireManager).IsCeasefireActive() || checkPacking && this.IsPacking() || this.CanPack() || !this.AbleToMove()) return false; // Move a tile outside the building. 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); } let garrisonHolder = this.IsGarrisoned() && type != "Ungarrison" ? this.GetGarrisonHolder() : null; // Special cases of orders that shouldn't be replaced: // 1. Cheering - we're invulnerable, add order after we finish // 2. Packing/unpacking - we're immobile, add order after we finish (unless it's cancel) // TODO: maybe a better way of doing this would be to use priority levels if (this.order && this.order.type == "Cheering") { var order = { "type": type, "data": data }; var cheeringOrder = this.orderQueue.shift(); this.orderQueue = [cheeringOrder, order]; } else if (this.IsPacking() && type != "CancelPack" && type != "CancelUnpack") { 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 { this.orderQueue = []; this.PushOrder(type, data); } if (garrisonHolder) this.PushOrder("Garrison", { "target": garrisonHolder }); 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 we are being re-affected to a work order, forget the previous ones if (isWorkType(type)) { this.workOrders = []; return; } // Then if we already have work orders, keep them if (this.workOrders.length) return; // First if the unit is in a formation, get its workOrders from it 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()) { let cmpGarrisonHolder = Engine.QueryInterface(this.GetGarrisonHolder(), IID_GarrisonHolder); if (!cmpGarrisonHolder || !cmpGarrisonHolder.PerformEject([this.entity], false)) return false; } // Clear the order queue considering special orders not to avoid if (this.order && this.order.type == "Cheering") { var cheeringOrder = this.orderQueue.shift(); this.orderQueue = [cheeringOrder]; } else this.orderQueue = []; this.AddOrders(this.workOrders); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); // And if the unit is in a formation, remove it from the formation 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) { 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.OnHealthChanged = function(msg) { this.UnitFsm.ProcessMessage(this, {"type": "HealthChanged", "from": msg.from, "to": msg.to}); }; 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}); }; //// 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; let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || cmpOwnership.GetOwner() == INVALID_PLAYER) return undefined; let owner = cmpOwnership.GetOwner(); // 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); let nearby = cmpRangeManager.ExecuteQueryAroundPos(position, 0, range, players, IID_ResourceSupply); return nearby.find(ent => { if (!this.CanGather(ent) || !this.CheckTargetVisible(ent)) return false; let cmpResourceSupply = Engine.QueryInterface(ent, IID_ResourceSupply); let type = cmpResourceSupply.GetType(); let amount = cmpResourceSupply.GetCurrentAmount(); let template = cmpTemplateManager.GetCurrentTemplateName(ent); // Remove "resource|" prefix from template names, if present. if (template.indexOf("resource|") != -1) template = template.slice(9); return amount > 0 && cmpResourceSupply.IsAvailable(owner, 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; // Find dropsites owned by this unit's player or allied ones if allowed. 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); 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; // Find buildings owned by this unit's player let players = [cmpOwnership.GetOwner()]; let range = 64; // TODO: what's a sensible number? let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); let nearby = cmpRangeManager.ExecuteQueryAroundPos(position, 0, range, players, IID_Foundation); // 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()); }; /** * Play a sound appropriate to the current entity. */ UnitAI.prototype.PlaySound = function(name) { // If we're a formation controller, use the sounds from our first member if (this.IsFormationController()) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); var member = cmpFormation.GetPrimaryMember(); if (member) PlaySound(name, member); } else { // Otherwise use our own sounds 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; // Special case for meat. 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); 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); if (!this.AbleToMove(cmpUnitMotion)) return false; let 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; let range = this.GetRange(IID_Attack, type); if (!range) return false; 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.IsInWorld()) return false; let t = targetCmpPosition.GetPosition(); // h is positive when I'm higher than the target let h = s.y - t.y + range.elevationBonus; 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; // The parabole changes while walking so be cautious: let 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); }; UnitAI.prototype.MoveToGarrisonRange = function(target) { if (!this.CheckTargetVisible(target)) return false; var cmpGarrisonHolder = Engine.QueryInterface(target, IID_GarrisonHolder); if (!cmpGarrisonHolder) return false; var range = cmpGarrisonHolder.GetLoadingRange(); 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); 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); if (type != "Ranged") return this.CheckTargetRange(target, IID_Attack, type); let targetCmpPosition = Engine.QueryInterface(target, IID_Position); if (!targetCmpPosition || !targetCmpPosition.IsInWorld()) return false; let range = this.GetRange(IID_Attack, type); if (!range) return false; let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!thisCmpPosition.IsInWorld()) return false; let s = thisCmpPosition.GetPosition(); let t = targetCmpPosition.GetPosition(); let h = s.y - t.y + range.elevationBonus; let maxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h); if (maxRange < 0) return false; let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, maxRange, false); }; 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); }; UnitAI.prototype.CheckGarrisonRange = function(target) { - var cmpGarrisonHolder = Engine.QueryInterface(target, IID_GarrisonHolder); + let cmpGarrisonHolder = Engine.QueryInterface(target, IID_GarrisonHolder); if (!cmpGarrisonHolder) return false; - var range = cmpGarrisonHolder.GetLoadingRange(); - var cmpObstruction = Engine.QueryInterface(this.entity, IID_Obstruction); - if (cmpObstruction) - range.max += cmpObstruction.GetUnitRadius()*1.5; // multiply by something larger than sqrt(2) - - let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); - return cmpObstructionManager.IsInTargetRange(this.entity, target, range.min, range.max, false); + let range = cmpGarrisonHolder.GetLoadingRange(); + return this.CheckTargetRangeExplicit(target, range.min, range.max); }; /** * Returns true if the target entity is visible through the FoW/SoD. */ UnitAI.prototype.CheckTargetVisible = function(target) { var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership) return false; var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (!cmpRangeManager) return false; // Entities that are hidden and miraged are considered visible var cmpFogging = Engine.QueryInterface(target, IID_Fogging); 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); 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 = 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.template.NaturalBehaviour && this.template.NaturalBehaviour == "skittish") { 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) { // 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) { var cmpUnitAI = Engine.QueryInterface(target, IID_UnitAI); var cmpAttack = Engine.QueryInterface(target, IID_Attack); if (cmpUnitAI && cmpAttack && cmpAttack.GetAttackTypes().some(type => cmpUnitAI.CheckTargetAttackRange(this.isGuardOf, type))) return false; } // Stop if we're in hold-ground mode and it's too far from the holding point 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; } // (Note that CCmpUnitMotion will detect if the target is lost in FoW, // and will continue moving to its last seen position and then stop) 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; } if (force) return true; return false; }; //// External interface functions //// 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) var cmpObstruction = Engine.QueryInterface(this.entity, IID_Obstruction); if (cmpObstruction) { if (ent == INVALID_ENTITY) cmpObstruction.SetControlGroup(this.entity); else cmpObstruction.SetControlGroup(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) || "special/formations/null"; }; 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(); // Add new order to move into formation at the current position 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": // Find the target unit's position var cmpTargetPosition = Engine.QueryInterface(order.data.target, IID_Position); if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) return targetPositions; targetPositions.push(cmpTargetPosition.GetPosition2D()); return targetPositions; case "Stop": return []; 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 the total distance to the end of the order queue return distance; }; UnitAI.prototype.AddOrder = function(type, data, queued) { if (this.expectedRoute) this.expectedRoute = undefined; if (queued) this.PushOrder(type, data); else { // May happen if an order arrives on the same turn the unit is garrisoned // in that case, just forget the order as this will lead to an infinite loop if (this.IsGarrisoned() && !this.IsTurret() && type != "Ungarrison") return; this.ReplaceOrder(type, data); } }; /** * Adds guard/escort order to the queue, forced by the player. */ UnitAI.prototype.Guard = function(target, queued) { if (!this.CanGuard()) { this.WalkToTarget(target, queued); return; } // if we already had an old guard order, do nothing if the target is the same // and the order is running, otherwise remove the previous order if (this.isGuardOf) { if (this.isGuardOf == target && this.order && this.order.type == "Guard") return; else this.RemoveGuard(); } this.AddOrder("Guard", { "target": target, "force": false }, queued); }; UnitAI.prototype.AddGuard = function(target) { if (!this.CanGuard()) return false; var cmpGuard = Engine.QueryInterface(target, IID_Guard); if (!cmpGuard) return false; // Do not allow to guard a unit already guarding var cmpUnitAI = Engine.QueryInterface(target, IID_UnitAI); if (cmpUnitAI && cmpUnitAI.IsGuardOf()) 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; // Do not let a unit already guarded to guard. This would work in principle, // but would clutter the gui with too much buttons to take all cases into account var cmpGuard = Engine.QueryInterface(this.entity, IID_Guard); if (cmpGuard && cmpGuard.GetEntities().length) return false; 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) { if (this.expectedRoute && queued) this.expectedRoute.push({ "x": x, "z": z }); else this.AddOrder("Walk", { "x": x, "z": z, "force": true }, queued); }; /** * Adds walk to point range order to queue, forced by the player. */ UnitAI.prototype.WalkToPointRange = function(x, z, min, max, queued) { this.AddOrder("Walk", { "x": x, "z": z, "min": min, "max": max, "force": true }, queued); }; /** * Adds stop order to queue, forced by the player. */ UnitAI.prototype.Stop = function(queued) { this.AddOrder("Stop", { "force": true }, queued); }; /** * 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) { this.AddOrder("WalkToTarget", { "target": target, "force": true }, queued); }; /** * 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) { this.AddOrder("WalkAndFight", { "x": x, "z": z, "targetClasses": targetClasses, "allowCapture": allowCapture, "force": true }, queued); }; UnitAI.prototype.Patrol = function(x, z, targetClasses, allowCapture = true, queued = false) { if (!this.CanPatrol()) { this.Walk(x, z, queued); return; } this.AddOrder("Patrol", { "x": x, "z": z, "targetClasses": targetClasses, "allowCapture": allowCapture, "force": true }, queued); }; /** * 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; // Ignore also the request if we are packing. 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) { 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); return; } let order = { "target": target, "force": true, "allowCapture": allowCapture, }; this.RememberTargetPosition(order); this.AddOrder("Attack", order, queued); }; /** * Adds garrison order to the queue, forced by the player. */ UnitAI.prototype.Garrison = function(target, queued) { if (target == this.entity) return; if (!this.CanGarrison(target)) { this.WalkToTarget(target, queued); return; } this.AddOrder("Garrison", { "target": target, "force": true }, queued); }; /** * Adds ungarrison order to the queue. */ UnitAI.prototype.Ungarrison = function() { if (this.IsGarrisoned()) { this.SetImmobile(false); this.AddOrder("Ungarrison", null, false); } }; /** * Adds a garrison order for units that are already garrisoned in the garrison holder. */ UnitAI.prototype.Autogarrison = function(target) { this.isGarrisoned = true; this.PushOrderFront("Garrison", { "target": target }); }; /** * Adds gather order to the queue, forced by the player * until the target is reached */ UnitAI.prototype.Gather = function(target, queued) { this.PerformGather(target, queued, true); }; /** * Internal function to abstract the force parameter. */ UnitAI.prototype.PerformGather = function(target, queued, force) { 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); // Remove "resource|" prefix from template name, if present. 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; this.AddOrder("Gather", order, queued); }; /** * 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) { // Remove "resource|" prefix from template name, if present. 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); else this.AddOrder("Walk", { "x": x, "z": z, "force": false }, queued); }; /** * Adds heal order to the queue, forced by the player. */ UnitAI.prototype.Heal = function(target, queued) { if (!this.CanHeal(target)) { this.WalkToTarget(target, queued); return; } this.AddOrder("Heal", { "target": target, "force": true }, queued); }; /** * Adds return resource order to the queue, forced by the player. */ UnitAI.prototype.ReturnResource = function(target, queued) { if (!this.CanReturnResource(target, true)) { this.WalkToTarget(target, queued); return; } this.AddOrder("ReturnResource", { "target": target, "force": true }, queued); }; 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) { 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); } else { if (this.IsFormationController()) this.CallMemberFunction("WalkToTarget", [cmpTrader.GetFirstMarket(), queued]); else this.WalkToTarget(cmpTrader.GetFirstMarket(), queued); 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) { if (this.waypoints && this.waypoints.length > 1) { let point = this.waypoints.pop(); return this.MoveToPoint(point.x, point.z) || this.MoveToMarket(targetMarket); } this.waypoints = undefined; return this.MoveToTargetRange(targetMarket, IID_Trader); }; UnitAI.prototype.PerformTradeAndMoveToNextMarket = function(currentMarket) { if (!this.CanTrade(currentMarket)) { this.StopTrading(); return; } if (!this.CheckTargetRange(currentMarket, IID_Trader)) { if (!this.MoveToMarket(currentMarket)) // If the current market is not reached try again this.StopTrading(); return; } let cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); let nextMarket = cmpTrader.PerformTrade(currentMarket); let amount = cmpTrader.GetGoods().amount; if (!nextMarket || !amount || !amount.traderGain) { this.StopTrading(); return; } 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.waypoints.unshift(null); // additionnal dummy point for the market } if (this.MoveToMarket(nextMarket)) // We've started walking to the next market this.SetNextState("APPROACHINGMARKET"); else this.StopTrading(); }; 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 }); }; UnitAI.prototype.StopTrading = function() { this.StopMoving(); this.FinishOrder(); var cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); cmpTrader.StopTrading(); }; /** * Adds repair/build order to the queue, forced by the player * until the target is reached */ UnitAI.prototype.Repair = function(target, autocontinue, queued) { if (!this.CanRepair(target)) { this.WalkToTarget(target, queued); return; } this.AddOrder("Repair", { "target": target, "autocontinue": autocontinue, "force": true }, queued); }; /** * Adds flee order to the queue, not forced, so it can be * interrupted by attacks. */ UnitAI.prototype.Flee = function(target, queued) { this.AddOrder("Flee", { "target": target, "force": false }, queued); }; /** * Pushes a cheer order to the front of the queue. Forced so it won't be interrupted by attacks. */ UnitAI.prototype.Cheer = function() { this.PushOrderFront("Cheering", { "force": true }); }; UnitAI.prototype.Pack = function(queued) { // Check that we can pack if (this.CanPack()) this.AddOrder("Pack", { "force": true }, queued); }; UnitAI.prototype.Unpack = function(queued) { // Check that we can unpack if (this.CanUnpack()) this.AddOrder("Unpack", { "force": true }, queued); }; UnitAI.prototype.CancelPack = function(queued) { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); if (cmpPack && cmpPack.IsPacking() && !cmpPack.IsPacked()) this.AddOrder("CancelPack", { "force": true }, queued); }; UnitAI.prototype.CancelUnpack = function(queued) { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); if (cmpPack && cmpPack.IsPacking() && cmpPack.IsPacked()) this.AddOrder("CancelUnpack", { "force": true }, queued); }; 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); // Stop moving if switching to stand ground // TODO: Also stop existing orders in a sensible way if (stance == "standground") this.StopMoving(); // Reset the range queries, since the range depends on stance. this.SetupRangeQueries(); }; UnitAI.prototype.SetTurretStance = function() { 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() { 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()) { var cmpUnitAI; var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); for (var ent of cmpFormation.members) { if (!(cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI))) continue; var targets = cmpUnitAI.GetTargetsFromUnit(); for (var targ of targets) { if (!cmpUnitAI.CanAttack(targ)) continue; if (this.order.data.targetClasses) { var cmpIdentity = Engine.QueryInterface(targ, IID_Identity); var targetClasses = this.order.data.targetClasses; if (targetClasses.attack && cmpIdentity && !MatchesClassList(cmpIdentity.GetClassesList(), targetClasses.attack)) continue; if (targetClasses.avoid && cmpIdentity && MatchesClassList(cmpIdentity.GetClassesList(), targetClasses.avoid)) continue; // Only used by the AIs to prevent some choices of targets if (targetClasses.vetoEntities && targetClasses.vetoEntities[targ]) continue; } this.PushOrderFront("Attack", { "target": targ, "force": false, "allowCapture": this.order.data.allowCapture }); return true; } } return false; } var targets = this.GetTargetsFromUnit(); for (var targ of targets) { if (!this.CanAttack(targ)) continue; if (this.order.data.targetClasses) { var cmpIdentity = Engine.QueryInterface(targ, IID_Identity); var targetClasses = this.order.data.targetClasses; if (cmpIdentity && targetClasses.attack && !MatchesClassList(cmpIdentity.GetClassesList(), targetClasses.attack)) continue; if (cmpIdentity && targetClasses.avoid && MatchesClassList(cmpIdentity.GetClassesList(), targetClasses.avoid)) continue; // Only used by the AIs to prevent some choices of targets if (targetClasses.vetoEntities && targetClasses.vetoEntities[targ]) 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.GetTargetsFromUnit = function() { if (!this.losAttackRangeQuery) return []; if (!this.GetStance().targetVisibleEnemies) return []; let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return []; let attackfilter = function(e) { 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 cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); let entities = cmpRangeManager.ResetActiveQuery(this.losAttackRangeQuery); let targets = entities.filter(function(v) { return cmpAttack.CanAttack(v) && attackfilter(v); }) .sort(function(a, b) { return cmpAttack.CompareEntitiesByPreference(a, b); }); return targets; }; 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); return true; } return false; }; //// Helper functions //// /** * General getter for ranges. * * @param {number} iid * @param {string} type - [Optional] * @return {Object | undefined} - The range in the form * { "min": number, "max": number } * Object."elevationBonus": number may be present when iid == IID_Attack. * Returns undefined when the entity does not have the requested component. */ UnitAI.prototype.GetRange = function(iid, type) { let component = Engine.QueryInterface(this.entity, iid); if (!component) return undefined; return component.GetRange(type); } 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 cmpGarrisonHolder = Engine.QueryInterface(target, IID_GarrisonHolder); if (!cmpGarrisonHolder) return false; // Verify that the target is owned by this entity's player or a mutual ally of this player let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || !(IsOwnedByPlayer(cmpOwnership.GetOwner(), target) || IsOwnedByMutualAllyOfPlayer(cmpOwnership.GetOwner(), target))) return false; return true; }; UnitAI.prototype.CanGather = function(target) { if (this.IsTurret()) return false; // The target must be a valid resource supply, or the mirage of one. var cmpResourceSupply = QueryMiragedInterface(target, IID_ResourceSupply); if (!cmpResourceSupply) return false; // Formation controllers should always respond to commands // (then the individual units can make up their own minds) if (this.IsFormationController()) return true; // Verify that we're able to respond to Gather commands var cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (!cmpResourceGatherer) return false; // Verify that we can gather from this target if (!cmpResourceGatherer.GetTargetGatherRate(target)) return false; // No need to verify ownership as we should be able to gather from // a target regardless of ownership. // No need to call "cmpResourceSupply.IsAvailable()" either because that // would cause units to walk to full entities instead of choosing another one // nearby to gather from, which is undesirable. return true; }; 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) { if (this.IsTurret()) return false; // Formation controllers should always respond to commands // (then the individual units can make up their own minds) if (this.IsFormationController()) return true; // Verify that we're able to respond to ReturnResource commands if (!cmpResourceGatherer) { cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (!cmpResourceGatherer) return false; } // Verify that the target is a dropsite let cmpResourceDropsite = Engine.QueryInterface(target, IID_ResourceDropsite); if (!cmpResourceDropsite) return false; if (checkCarriedResource) { // Verify that we are carrying some resources, // and can return our current resource to this target let type = cmpResourceGatherer.GetMainCarryingType(); if (!type || !cmpResourceDropsite.AcceptsType(type)) return false; } // Verify that the dropsite is owned by this entity's player (or a mutual ally's if allowed) let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (cmpOwnership && IsOwnedByPlayer(cmpOwnership.GetOwner(), target)) return true; let cmpPlayer = QueryOwnerInterface(this.entity); return cmpPlayer && cmpPlayer.HasSharedDropsites() && cmpResourceDropsite.IsShared() && cmpOwnership && IsOwnedByMutualAllyOfPlayer(cmpOwnership.GetOwner(), target); }; UnitAI.prototype.CanTrade = function(target) { if (this.IsTurret()) return false; // Formation controllers should always respond to commands // (then the individual units can make up their own minds) if (this.IsFormationController()) return true; // Verify that we're able to respond to Trade commands var cmpTrader = Engine.QueryInterface(this.entity, IID_Trader); return cmpTrader && cmpTrader.CanTrade(target); }; UnitAI.prototype.CanRepair = function(target) { if (this.IsTurret()) return false; // Formation controllers should always respond to commands // (then the individual units can make up their own minds) if (this.IsFormationController()) return true; // Verify that we're able to respond to Repair (Builder) commands var cmpBuilder = Engine.QueryInterface(this.entity, IID_Builder); if (!cmpBuilder) return false; // Verify that the target can be either built or repaired var cmpFoundation = QueryMiragedInterface(target, IID_Foundation); var cmpRepairable = Engine.QueryInterface(target, IID_Repairable); if (!cmpFoundation && !cmpRepairable) return false; // Verify that the target is owned by an ally of this entity's player var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); return cmpOwnership && IsOwnedByAllyOfPlayer(cmpOwnership.GetOwner(), target); }; UnitAI.prototype.CanPack = function() { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); return cmpPack && !cmpPack.IsPacking() && !cmpPack.IsPacked(); }; UnitAI.prototype.CanUnpack = function() { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); return cmpPack && !cmpPack.IsPacking() && cmpPack.IsPacked(); }; UnitAI.prototype.IsPacking = function() { var 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"; }; //// Animal specific functions //// UnitAI.prototype.MoveRandomly = function(distance) { // To minimize drift all across the map, animals 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; var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return false; var attackfilter = function(e) { var cmpOwnership = Engine.QueryInterface(e, IID_Ownership); if (cmpOwnership && cmpOwnership.GetOwner() > 0) return true; var 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 resetWaitingEntities - If true, call ResetWaitingEntities 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, resetWaitingEntities = true) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (!cmpFormation) return; if (resetWaitingEntities) cmpFormation.ResetWaitingEntities(); cmpFormation.GetMembers().forEach(ent => { let cmpUnitAI = Engine.QueryInterface(ent, 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) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (!cmpFormation) return false; return cmpFormation.GetMembers().every(ent => { var 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/CCmpPathfinder.cpp =================================================================== --- ps/trunk/source/simulation2/components/CCmpPathfinder.cpp (revision 23924) +++ ps/trunk/source/simulation2/components/CCmpPathfinder.cpp (revision 23925) @@ -1,972 +1,984 @@ /* Copyright (C) 2020 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 . */ /** * @file * Common code and setup code for CCmpPathfinder. */ #include "precompiled.h" #include "CCmpPathfinder_Common.h" #include "ps/CLogger.h" #include "ps/CStr.h" #include "ps/Profile.h" #include "ps/XML/Xeromyces.h" #include "renderer/Scene.h" #include "simulation2/MessageTypes.h" #include "simulation2/components/ICmpObstruction.h" #include "simulation2/components/ICmpObstructionManager.h" #include "simulation2/components/ICmpTerrain.h" #include "simulation2/components/ICmpWaterManager.h" #include "simulation2/helpers/HierarchicalPathfinder.h" #include "simulation2/helpers/LongPathfinder.h" #include "simulation2/helpers/MapEdgeTiles.h" #include "simulation2/helpers/Rasterize.h" #include "simulation2/helpers/VertexPathfinder.h" #include "simulation2/serialization/SerializeTemplates.h" REGISTER_COMPONENT_TYPE(Pathfinder) void CCmpPathfinder::Init(const CParamNode& UNUSED(paramNode)) { m_MapSize = 0; m_Grid = NULL; m_TerrainOnlyGrid = NULL; FlushAIPathfinderDirtinessInformation(); m_NextAsyncTicket = 1; m_AtlasOverlay = NULL; m_VertexPathfinder = std::unique_ptr(new VertexPathfinder(m_MapSize, m_TerrainOnlyGrid)); m_LongPathfinder = std::unique_ptr(new LongPathfinder()); m_PathfinderHier = std::unique_ptr(new HierarchicalPathfinder()); // Register Relax NG validator CXeromyces::AddValidator(g_VFS, "pathfinder", "simulation/data/pathfinder.rng"); // Since this is used as a system component (not loaded from an entity template), // we can't use the real paramNode (it won't get handled properly when deserializing), // so load the data from a special XML file. CParamNode externalParamNode; CParamNode::LoadXML(externalParamNode, L"simulation/data/pathfinder.xml", "pathfinder"); // Previously all move commands during a turn were // queued up and processed asynchronously at the start // of the next turn. Now we are processing queued up // events several times duing the turn. This improves // responsiveness and units move more smoothly especially. // when in formation. There is still a call at the // beginning of a turn to process all outstanding moves - // this will handle any moves above the MaxSameTurnMoves // threshold. // // TODO - The moves processed at the beginning of the // turn do not count against the maximum moves per turn // currently. The thinking is that this will eventually // happen in another thread. Either way this probably // will require some adjustment and rethinking. const CParamNode pathingSettings = externalParamNode.GetChild("Pathfinder"); m_MaxSameTurnMoves = (u16)pathingSettings.GetChild("MaxSameTurnMoves").ToInt(); const CParamNode::ChildrenMap& passClasses = externalParamNode.GetChild("Pathfinder").GetChild("PassabilityClasses").GetChildren(); for (CParamNode::ChildrenMap::const_iterator it = passClasses.begin(); it != passClasses.end(); ++it) { std::string name = it->first; ENSURE((int)m_PassClasses.size() <= PASS_CLASS_BITS); pass_class_t mask = PASS_CLASS_MASK_FROM_INDEX(m_PassClasses.size()); m_PassClasses.push_back(PathfinderPassability(mask, it->second)); m_PassClassMasks[name] = mask; } m_Workers.emplace_back(PathfinderWorker{}); } CCmpPathfinder::~CCmpPathfinder() {}; void CCmpPathfinder::Deinit() { m_Workers.clear(); SetDebugOverlay(false); // cleans up memory SAFE_DELETE(m_AtlasOverlay); SAFE_DELETE(m_Grid); SAFE_DELETE(m_TerrainOnlyGrid); } struct SerializeLongRequest { template void operator()(S& serialize, const char* UNUSED(name), LongPathRequest& value) { serialize.NumberU32_Unbounded("ticket", value.ticket); serialize.NumberFixed_Unbounded("x0", value.x0); serialize.NumberFixed_Unbounded("z0", value.z0); SerializeGoal()(serialize, "goal", value.goal); serialize.NumberU16_Unbounded("pass class", value.passClass); serialize.NumberU32_Unbounded("notify", value.notify); } }; struct SerializeShortRequest { template void operator()(S& serialize, const char* UNUSED(name), ShortPathRequest& value) { serialize.NumberU32_Unbounded("ticket", value.ticket); serialize.NumberFixed_Unbounded("x0", value.x0); serialize.NumberFixed_Unbounded("z0", value.z0); serialize.NumberFixed_Unbounded("clearance", value.clearance); serialize.NumberFixed_Unbounded("range", value.range); SerializeGoal()(serialize, "goal", value.goal); serialize.NumberU16_Unbounded("pass class", value.passClass); serialize.Bool("avoid moving units", value.avoidMovingUnits); serialize.NumberU32_Unbounded("group", value.group); serialize.NumberU32_Unbounded("notify", value.notify); } }; template void CCmpPathfinder::SerializeCommon(S& serialize) { SerializeVector()(serialize, "long requests", m_LongPathRequests); SerializeVector()(serialize, "short requests", m_ShortPathRequests); serialize.NumberU32_Unbounded("next ticket", m_NextAsyncTicket); serialize.NumberU16_Unbounded("map size", m_MapSize); } void CCmpPathfinder::Serialize(ISerializer& serialize) { SerializeCommon(serialize); } void CCmpPathfinder::Deserialize(const CParamNode& paramNode, IDeserializer& deserialize) { Init(paramNode); SerializeCommon(deserialize); } void CCmpPathfinder::HandleMessage(const CMessage& msg, bool UNUSED(global)) { switch (msg.GetType()) { case MT_RenderSubmit: { const CMessageRenderSubmit& msgData = static_cast (msg); RenderSubmit(msgData.collector); break; } case MT_TerrainChanged: { const CMessageTerrainChanged& msgData = static_cast(msg); m_TerrainDirty = true; MinimalTerrainUpdate(msgData.i0, msgData.j0, msgData.i1, msgData.j1); break; } case MT_WaterChanged: case MT_ObstructionMapShapeChanged: m_TerrainDirty = true; UpdateGrid(); break; case MT_Deserialized: UpdateGrid(); // In case we were serialised with requests pending, we need to process them. if (!m_ShortPathRequests.empty() || !m_LongPathRequests.empty()) { ENSURE(CmpPtr(GetSystemEntity())); StartProcessingMoves(false); } break; } } void CCmpPathfinder::RenderSubmit(SceneCollector& collector) { m_VertexPathfinder->RenderSubmit(collector); m_PathfinderHier->RenderSubmit(collector); } void CCmpPathfinder::SetDebugPath(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass) { m_LongPathfinder->SetDebugPath(*m_PathfinderHier, x0, z0, goal, passClass); } void CCmpPathfinder::SetDebugOverlay(bool enabled) { m_VertexPathfinder->SetDebugOverlay(enabled); m_LongPathfinder->SetDebugOverlay(enabled); } void CCmpPathfinder::SetHierDebugOverlay(bool enabled) { m_PathfinderHier->SetDebugOverlay(enabled, &GetSimContext()); } void CCmpPathfinder::GetDebugData(u32& steps, double& time, Grid& grid) const { m_LongPathfinder->GetDebugData(steps, time, grid); } void CCmpPathfinder::SetAtlasOverlay(bool enable, pass_class_t passClass) { if (enable) { if (!m_AtlasOverlay) m_AtlasOverlay = new AtlasOverlay(this, passClass); m_AtlasOverlay->m_PassClass = passClass; } else SAFE_DELETE(m_AtlasOverlay); } pass_class_t CCmpPathfinder::GetPassabilityClass(const std::string& name) const { std::map::const_iterator it = m_PassClassMasks.find(name); if (it == m_PassClassMasks.end()) { LOGERROR("Invalid passability class name '%s'", name.c_str()); return 0; } return it->second; } void CCmpPathfinder::GetPassabilityClasses(std::map& passClasses) const { passClasses = m_PassClassMasks; } void CCmpPathfinder::GetPassabilityClasses(std::map& nonPathfindingPassClasses, std::map& pathfindingPassClasses) const { for (const std::pair& pair : m_PassClassMasks) { if ((GetPassabilityFromMask(pair.second)->m_Obstructions == PathfinderPassability::PATHFINDING)) pathfindingPassClasses[pair.first] = pair.second; else nonPathfindingPassClasses[pair.first] = pair.second; } } const PathfinderPassability* CCmpPathfinder::GetPassabilityFromMask(pass_class_t passClass) const { for (const PathfinderPassability& passability : m_PassClasses) { if (passability.m_Mask == passClass) return &passability; } return NULL; } const Grid& CCmpPathfinder::GetPassabilityGrid() { if (!m_Grid) UpdateGrid(); return *m_Grid; } /** * Given a grid of passable/impassable navcells (based on some passability mask), * computes a new grid where a navcell is impassable (per that mask) if * it is <=clearance navcells away from an impassable navcell in the original grid. * The results are ORed onto the original grid. * * This is used for adding clearance onto terrain-based navcell passability. * * TODO PATHFINDER: might be nicer to get rounded corners by measuring clearances as * Euclidean distances; currently it effectively does dist=max(dx,dy) instead. * This would only really be a problem for big clearances. */ static void ExpandImpassableCells(Grid& grid, u16 clearance, pass_class_t mask) { PROFILE3("ExpandImpassableCells"); u16 w = grid.m_W; u16 h = grid.m_H; // First expand impassable cells horizontally into a temporary 1-bit grid Grid tempGrid(w, h); for (u16 j = 0; j < h; ++j) { // New cell (i,j) is blocked if (i',j) blocked for any i-clearance <= i' <= i+clearance // Count the number of blocked cells around i=0 u16 numBlocked = 0; for (u16 i = 0; i <= clearance && i < w; ++i) if (!IS_PASSABLE(grid.get(i, j), mask)) ++numBlocked; for (u16 i = 0; i < w; ++i) { // Store a flag if blocked by at least one nearby cell if (numBlocked) tempGrid.set(i, j, 1); // Slide the numBlocked window along: // remove the old i-clearance value, add the new (i+1)+clearance // (avoiding overflowing the grid) if (i >= clearance && !IS_PASSABLE(grid.get(i-clearance, j), mask)) --numBlocked; if (i+1+clearance < w && !IS_PASSABLE(grid.get(i+1+clearance, j), mask)) ++numBlocked; } } for (u16 i = 0; i < w; ++i) { // New cell (i,j) is blocked if (i,j') blocked for any j-clearance <= j' <= j+clearance // Count the number of blocked cells around j=0 u16 numBlocked = 0; for (u16 j = 0; j <= clearance && j < h; ++j) if (tempGrid.get(i, j)) ++numBlocked; for (u16 j = 0; j < h; ++j) { // Add the mask if blocked by at least one nearby cell if (numBlocked) grid.set(i, j, grid.get(i, j) | mask); // Slide the numBlocked window along: // remove the old j-clearance value, add the new (j+1)+clearance // (avoiding overflowing the grid) if (j >= clearance && tempGrid.get(i, j-clearance)) --numBlocked; if (j+1+clearance < h && tempGrid.get(i, j+1+clearance)) ++numBlocked; } } } Grid CCmpPathfinder::ComputeShoreGrid(bool expandOnWater) { PROFILE3("ComputeShoreGrid"); CmpPtr cmpWaterManager(GetSystemEntity()); // TODO: these bits should come from ICmpTerrain CTerrain& terrain = GetSimContext().GetTerrain(); // avoid integer overflow in intermediate calculation const u16 shoreMax = 32767; // First pass - find underwater tiles Grid waterGrid(m_MapSize, m_MapSize); for (u16 j = 0; j < m_MapSize; ++j) { for (u16 i = 0; i < m_MapSize; ++i) { fixed x, z; Pathfinding::TileCenter(i, j, x, z); bool underWater = cmpWaterManager && (cmpWaterManager->GetWaterLevel(x, z) > terrain.GetExactGroundLevelFixed(x, z)); waterGrid.set(i, j, underWater ? 1 : 0); } } // Second pass - find shore tiles Grid shoreGrid(m_MapSize, m_MapSize); for (u16 j = 0; j < m_MapSize; ++j) { for (u16 i = 0; i < m_MapSize; ++i) { // Find a land tile if (!waterGrid.get(i, j)) { // If it's bordered by water, it's a shore tile if ((i > 0 && waterGrid.get(i-1, j)) || (i > 0 && j < m_MapSize-1 && waterGrid.get(i-1, j+1)) || (i > 0 && j > 0 && waterGrid.get(i-1, j-1)) || (i < m_MapSize-1 && waterGrid.get(i+1, j)) || (i < m_MapSize-1 && j < m_MapSize-1 && waterGrid.get(i+1, j+1)) || (i < m_MapSize-1 && j > 0 && waterGrid.get(i+1, j-1)) || (j > 0 && waterGrid.get(i, j-1)) || (j < m_MapSize-1 && waterGrid.get(i, j+1)) ) shoreGrid.set(i, j, 0); else shoreGrid.set(i, j, shoreMax); } // If we want to expand on water, we want water tiles not to be shore tiles else if (expandOnWater) shoreGrid.set(i, j, shoreMax); } } // Expand influences on land to find shore distance for (u16 y = 0; y < m_MapSize; ++y) { u16 min = shoreMax; for (u16 x = 0; x < m_MapSize; ++x) { if (!waterGrid.get(x, y) || expandOnWater) { u16 g = shoreGrid.get(x, y); if (g > min) shoreGrid.set(x, y, min); else if (g < min) min = g; ++min; } } for (u16 x = m_MapSize; x > 0; --x) { if (!waterGrid.get(x-1, y) || expandOnWater) { u16 g = shoreGrid.get(x-1, y); if (g > min) shoreGrid.set(x-1, y, min); else if (g < min) min = g; ++min; } } } for (u16 x = 0; x < m_MapSize; ++x) { u16 min = shoreMax; for (u16 y = 0; y < m_MapSize; ++y) { if (!waterGrid.get(x, y) || expandOnWater) { u16 g = shoreGrid.get(x, y); if (g > min) shoreGrid.set(x, y, min); else if (g < min) min = g; ++min; } } for (u16 y = m_MapSize; y > 0; --y) { if (!waterGrid.get(x, y-1) || expandOnWater) { u16 g = shoreGrid.get(x, y-1); if (g > min) shoreGrid.set(x, y-1, min); else if (g < min) min = g; ++min; } } } return shoreGrid; } void CCmpPathfinder::UpdateGrid() { PROFILE3("UpdateGrid"); CmpPtr cmpTerrain(GetSimContext(), SYSTEM_ENTITY); if (!cmpTerrain) return; // error u16 terrainSize = cmpTerrain->GetTilesPerSide(); if (terrainSize == 0) return; // If the terrain was resized then delete the old grid data if (m_Grid && m_MapSize != terrainSize) { SAFE_DELETE(m_Grid); SAFE_DELETE(m_TerrainOnlyGrid); } // Initialise the terrain data when first needed if (!m_Grid) { m_MapSize = terrainSize; m_Grid = new Grid(m_MapSize * Pathfinding::NAVCELLS_PER_TILE, m_MapSize * Pathfinding::NAVCELLS_PER_TILE); SAFE_DELETE(m_TerrainOnlyGrid); m_TerrainOnlyGrid = new Grid(m_MapSize * Pathfinding::NAVCELLS_PER_TILE, m_MapSize * Pathfinding::NAVCELLS_PER_TILE); m_DirtinessInformation = { true, true, Grid(m_MapSize * Pathfinding::NAVCELLS_PER_TILE, m_MapSize * Pathfinding::NAVCELLS_PER_TILE) }; m_AIPathfinderDirtinessInformation = m_DirtinessInformation; m_TerrainDirty = true; } // The grid should be properly initialized and clean. Checking the latter is expensive so do it only for debugging. #ifdef NDEBUG ENSURE(m_DirtinessInformation.dirtinessGrid.compare_sizes(m_Grid)); #else ENSURE(m_DirtinessInformation.dirtinessGrid == Grid(m_MapSize * Pathfinding::NAVCELLS_PER_TILE, m_MapSize * Pathfinding::NAVCELLS_PER_TILE)); #endif CmpPtr cmpObstructionManager(GetSimContext(), SYSTEM_ENTITY); cmpObstructionManager->UpdateInformations(m_DirtinessInformation); if (!m_DirtinessInformation.dirty && !m_TerrainDirty) return; // If the terrain has changed, recompute m_Grid // Else, use data from m_TerrainOnlyGrid and add obstructions if (m_TerrainDirty) { TerrainUpdateHelper(); *m_Grid = *m_TerrainOnlyGrid; m_TerrainDirty = false; m_DirtinessInformation.globallyDirty = true; } else if (m_DirtinessInformation.globallyDirty) { ENSURE(m_Grid->compare_sizes(m_TerrainOnlyGrid)); memcpy(m_Grid->m_Data, m_TerrainOnlyGrid->m_Data, (m_Grid->m_W)*(m_Grid->m_H)*sizeof(NavcellData)); } else { ENSURE(m_Grid->compare_sizes(m_TerrainOnlyGrid)); for (u16 j = 0; j < m_DirtinessInformation.dirtinessGrid.m_H; ++j) for (u16 i = 0; i < m_DirtinessInformation.dirtinessGrid.m_W; ++i) if (m_DirtinessInformation.dirtinessGrid.get(i, j) == 1) m_Grid->set(i, j, m_TerrainOnlyGrid->get(i, j)); } // Add obstructions onto the grid cmpObstructionManager->Rasterize(*m_Grid, m_PassClasses, m_DirtinessInformation.globallyDirty); // Update the long-range and hierarchical pathfinders. if (m_DirtinessInformation.globallyDirty) { std::map nonPathfindingPassClasses, pathfindingPassClasses; GetPassabilityClasses(nonPathfindingPassClasses, pathfindingPassClasses); m_LongPathfinder->Reload(m_Grid); m_PathfinderHier->Recompute(m_Grid, nonPathfindingPassClasses, pathfindingPassClasses); } else { m_LongPathfinder->Update(m_Grid); m_PathfinderHier->Update(m_Grid, m_DirtinessInformation.dirtinessGrid); } // Remember the necessary updates that the AI pathfinder will have to perform as well m_AIPathfinderDirtinessInformation.MergeAndClear(m_DirtinessInformation); } void CCmpPathfinder::MinimalTerrainUpdate(int itile0, int jtile0, int itile1, int jtile1) { TerrainUpdateHelper(false, itile0, jtile0, itile1, jtile1); } void CCmpPathfinder::TerrainUpdateHelper(bool expandPassability, int itile0, int jtile0, int itile1, int jtile1) { PROFILE3("TerrainUpdateHelper"); CmpPtr cmpObstructionManager(GetSimContext(), SYSTEM_ENTITY); CmpPtr cmpWaterManager(GetSimContext(), SYSTEM_ENTITY); CmpPtr cmpTerrain(GetSimContext(), SYSTEM_ENTITY); CTerrain& terrain = GetSimContext().GetTerrain(); if (!cmpTerrain || !cmpObstructionManager) return; u16 terrainSize = cmpTerrain->GetTilesPerSide(); if (terrainSize == 0) return; const bool needsNewTerrainGrid = !m_TerrainOnlyGrid || m_MapSize != terrainSize; if (needsNewTerrainGrid) { m_MapSize = terrainSize; SAFE_DELETE(m_TerrainOnlyGrid); m_TerrainOnlyGrid = new Grid(m_MapSize * Pathfinding::NAVCELLS_PER_TILE, m_MapSize * Pathfinding::NAVCELLS_PER_TILE); // If this update comes from a map resizing, we must reinitialize the other grids as well if (!m_TerrainOnlyGrid->compare_sizes(m_Grid)) { SAFE_DELETE(m_Grid); m_Grid = new Grid(m_MapSize * Pathfinding::NAVCELLS_PER_TILE, m_MapSize * Pathfinding::NAVCELLS_PER_TILE); m_DirtinessInformation = { true, true, Grid(m_MapSize * Pathfinding::NAVCELLS_PER_TILE, m_MapSize * Pathfinding::NAVCELLS_PER_TILE) }; m_AIPathfinderDirtinessInformation = m_DirtinessInformation; } } Grid shoreGrid = ComputeShoreGrid(); const bool partialTerrainGridUpdate = !expandPassability && !needsNewTerrainGrid && itile0 != -1 && jtile0 != -1 && itile1 != -1 && jtile1 != -1; int istart = 0, iend = m_MapSize * Pathfinding::NAVCELLS_PER_TILE; int jstart = 0, jend = m_MapSize * Pathfinding::NAVCELLS_PER_TILE; if (partialTerrainGridUpdate) { // We need to extend the boundaries by 1 tile, because slope and ground // level are calculated by multiple neighboring tiles. // TODO: add CTerrain constant instead of 1. istart = Clamp(itile0 - 1, 0, static_cast(m_MapSize)) * Pathfinding::NAVCELLS_PER_TILE; iend = Clamp(itile1 + 1, 0, static_cast(m_MapSize)) * Pathfinding::NAVCELLS_PER_TILE; jstart = Clamp(jtile0 - 1, 0, static_cast(m_MapSize)) * Pathfinding::NAVCELLS_PER_TILE; jend = Clamp(jtile1 + 1, 0, static_cast(m_MapSize)) * Pathfinding::NAVCELLS_PER_TILE; } // Compute initial terrain-dependent passability for (int j = jstart; j < jend; ++j) { for (int i = istart; i < iend; ++i) { // World-space coordinates for this navcell fixed x, z; Pathfinding::NavcellCenter(i, j, x, z); // Terrain-tile coordinates for this navcell int itile = i / Pathfinding::NAVCELLS_PER_TILE; int jtile = j / Pathfinding::NAVCELLS_PER_TILE; // Gather all the data potentially needed to determine passability: fixed height = terrain.GetExactGroundLevelFixed(x, z); fixed water; if (cmpWaterManager) water = cmpWaterManager->GetWaterLevel(x, z); fixed depth = water - height; // Exact slopes give kind of weird output, so just use rough tile-based slopes fixed slope = terrain.GetSlopeFixed(itile, jtile); // Get world-space coordinates from shoreGrid (which uses terrain tiles) fixed shoredist = fixed::FromInt(shoreGrid.get(itile, jtile)).MultiplyClamp(TERRAIN_TILE_SIZE); // Compute the passability for every class for this cell NavcellData t = 0; for (const PathfinderPassability& passability : m_PassClasses) if (!passability.IsPassable(depth, slope, shoredist)) t |= passability.m_Mask; m_TerrainOnlyGrid->set(i, j, t); } } // Compute off-world passability const int edgeSize = MAP_EDGE_TILES * Pathfinding::NAVCELLS_PER_TILE; NavcellData edgeMask = 0; for (const PathfinderPassability& passability : m_PassClasses) edgeMask |= passability.m_Mask; int w = m_TerrainOnlyGrid->m_W; int h = m_TerrainOnlyGrid->m_H; if (cmpObstructionManager->GetPassabilityCircular()) { for (int j = jstart; j < jend; ++j) { for (int i = istart; i < iend; ++i) { // Based on CCmpRangeManager::LosIsOffWorld // but tweaked since it's tile-based instead. // (We double all the values so we can handle half-tile coordinates.) // This needs to be slightly tighter than the LOS circle, // else units might get themselves lost in the SoD around the edge. int dist2 = (i*2 + 1 - w)*(i*2 + 1 - w) + (j*2 + 1 - h)*(j*2 + 1 - h); if (dist2 >= (w - 2*edgeSize) * (h - 2*edgeSize)) m_TerrainOnlyGrid->set(i, j, m_TerrainOnlyGrid->get(i, j) | edgeMask); } } } else { for (u16 j = 0; j < h; ++j) for (u16 i = 0; i < edgeSize; ++i) m_TerrainOnlyGrid->set(i, j, m_TerrainOnlyGrid->get(i, j) | edgeMask); for (u16 j = 0; j < h; ++j) for (u16 i = w-edgeSize+1; i < w; ++i) m_TerrainOnlyGrid->set(i, j, m_TerrainOnlyGrid->get(i, j) | edgeMask); for (u16 j = 0; j < edgeSize; ++j) for (u16 i = edgeSize; i < w-edgeSize+1; ++i) m_TerrainOnlyGrid->set(i, j, m_TerrainOnlyGrid->get(i, j) | edgeMask); for (u16 j = h-edgeSize+1; j < h; ++j) for (u16 i = edgeSize; i < w-edgeSize+1; ++i) m_TerrainOnlyGrid->set(i, j, m_TerrainOnlyGrid->get(i, j) | edgeMask); } if (!expandPassability) return; // Expand the impassability grid, for any class with non-zero clearance, // so that we can stop units getting too close to impassable navcells. // Note: It's not possible to perform this expansion once for all passabilities // with the same clearance, because the impassable cells are not necessarily the // same for all these passabilities. for (PathfinderPassability& passability : m_PassClasses) { if (passability.m_Clearance == fixed::Zero()) continue; int clearance = (passability.m_Clearance / Pathfinding::NAVCELL_SIZE).ToInt_RoundToInfinity(); ExpandImpassableCells(*m_TerrainOnlyGrid, clearance, passability.m_Mask); } } ////////////////////////////////////////////////////////// // Async pathfinder workers CCmpPathfinder::PathfinderWorker::PathfinderWorker() {} template void CCmpPathfinder::PathfinderWorker::PushRequests(std::vector&, ssize_t) { static_assert(sizeof(T) == 0, "Only specializations can be used"); } template<> void CCmpPathfinder::PathfinderWorker::PushRequests(std::vector& from, ssize_t amount) { m_LongRequests.insert(m_LongRequests.end(), std::make_move_iterator(from.end() - amount), std::make_move_iterator(from.end())); } template<> void CCmpPathfinder::PathfinderWorker::PushRequests(std::vector& from, ssize_t amount) { m_ShortRequests.insert(m_ShortRequests.end(), std::make_move_iterator(from.end() - amount), std::make_move_iterator(from.end())); } void CCmpPathfinder::PathfinderWorker::Work(const CCmpPathfinder& pathfinder) { while (!m_LongRequests.empty()) { const LongPathRequest& req = m_LongRequests.back(); WaypointPath path; pathfinder.m_LongPathfinder->ComputePath(*pathfinder.m_PathfinderHier, req.x0, req.z0, req.goal, req.passClass, path); m_Results.emplace_back(req.ticket, req.notify, path); m_LongRequests.pop_back(); } while (!m_ShortRequests.empty()) { const ShortPathRequest& req = m_ShortRequests.back(); WaypointPath path = pathfinder.m_VertexPathfinder->ComputeShortPath(req, CmpPtr(pathfinder.GetSystemEntity())); m_Results.emplace_back(req.ticket, req.notify, path); m_ShortRequests.pop_back(); } } u32 CCmpPathfinder::ComputePathAsync(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass, entity_id_t notify) { LongPathRequest req = { m_NextAsyncTicket++, x0, z0, goal, passClass, notify }; m_LongPathRequests.push_back(req); return req.ticket; } u32 CCmpPathfinder::ComputeShortPathAsync(entity_pos_t x0, entity_pos_t z0, entity_pos_t clearance, entity_pos_t range, const PathGoal& goal, pass_class_t passClass, bool avoidMovingUnits, entity_id_t group, entity_id_t notify) { ShortPathRequest req = { m_NextAsyncTicket++, x0, z0, clearance, range, goal, passClass, avoidMovingUnits, group, notify }; m_ShortPathRequests.push_back(req); return req.ticket; } void CCmpPathfinder::ComputePathImmediate(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass, WaypointPath& ret) const { m_LongPathfinder->ComputePath(*m_PathfinderHier, x0, z0, goal, passClass, ret); } WaypointPath CCmpPathfinder::ComputeShortPathImmediate(const ShortPathRequest& request) const { return m_VertexPathfinder->ComputeShortPath(request, CmpPtr(GetSystemEntity())); } void CCmpPathfinder::FetchAsyncResultsAndSendMessages() { PROFILE2("FetchAsyncResults"); // We may now clear existing requests. m_ShortPathRequests.clear(); m_LongPathRequests.clear(); // WARNING: the order in which moves are pulled must be consistent when using 1 or n workers. // We fetch in the same order we inserted in, but we push moves backwards, so this works. std::vector results; for (PathfinderWorker& worker : m_Workers) { results.insert(results.end(), std::make_move_iterator(worker.m_Results.begin()), std::make_move_iterator(worker.m_Results.end())); worker.m_Results.clear(); } { PROFILE2("PostMessages"); for (PathResult& path : results) { CMessagePathResult msg(path.ticket, path.path); GetSimContext().GetComponentManager().PostMessage(path.notify, msg); } } } void CCmpPathfinder::StartProcessingMoves(bool useMax) { std::vector longRequests = GetMovesToProcess(m_LongPathRequests, useMax, m_MaxSameTurnMoves); std::vector shortRequests = GetMovesToProcess(m_ShortPathRequests, useMax, m_MaxSameTurnMoves - longRequests.size()); PushRequestsToWorkers(longRequests); PushRequestsToWorkers(shortRequests); for (PathfinderWorker& worker : m_Workers) worker.Work(*this); } template std::vector CCmpPathfinder::GetMovesToProcess(std::vector& requests, bool useMax, size_t maxMoves) { // Keep the original requests in which we need to serialize. std::vector copiedRequests; if (useMax) { size_t amount = std::min(requests.size(), maxMoves); if (amount > 0) copiedRequests.insert(copiedRequests.begin(), requests.end() - amount, requests.end()); } else copiedRequests = requests; return copiedRequests; } template void CCmpPathfinder::PushRequestsToWorkers(std::vector& from) { if (from.empty()) return; // Trivial load-balancing, / rounds towards zero so add 1 to ensure we do push all requests. size_t amount = from.size() / m_Workers.size() + 1; // WARNING: the order in which moves are pushed must be consistent when using 1 or n workers. // In this instance, work is distributed in a strict LIFO order, effectively reversing tickets. for (PathfinderWorker& worker : m_Workers) { amount = std::min(amount, from.size()); // Since we are rounding up before, ensure we aren't pushing beyond the end. worker.PushRequests(from, amount); from.erase(from.end() - amount, from.end()); } } ////////////////////////////////////////////////////////// +bool CCmpPathfinder::IsGoalReachable(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass) +{ + PROFILE2("IsGoalReachable"); + + u16 i, j; + Pathfinding::NearestNavcell(x0, z0, i, j, m_MapSize*Pathfinding::NAVCELLS_PER_TILE, m_MapSize*Pathfinding::NAVCELLS_PER_TILE); + if (!IS_PASSABLE(m_Grid->get(i, j), passClass)) + m_PathfinderHier->FindNearestPassableNavcell(i, j, passClass); + + return m_PathfinderHier->IsGoalReachable(i, j, goal, passClass); +} + bool CCmpPathfinder::CheckMovement(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, entity_pos_t r, pass_class_t passClass) const { PROFILE2_IFSPIKE("Check Movement", 0.001); // Test against obstructions first. filter may discard pathfinding-blocking obstructions. // Use more permissive version of TestLine to allow unit-unit collisions to overlap slightly. // This makes movement smoother and more natural for units, overall. CmpPtr cmpObstructionManager(GetSystemEntity()); if (!cmpObstructionManager || cmpObstructionManager->TestLine(filter, x0, z0, x1, z1, r, true)) return false; // Then test against the terrain grid. This should not be necessary // But in case we allow terrain to change it will become so. return Pathfinding::CheckLineMovement(x0, z0, x1, z1, passClass, *m_TerrainOnlyGrid); } ICmpObstruction::EFoundationCheck CCmpPathfinder::CheckUnitPlacement(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t r, pass_class_t passClass, bool UNUSED(onlyCenterPoint)) const { // Check unit obstruction CmpPtr cmpObstructionManager(GetSystemEntity()); if (!cmpObstructionManager) return ICmpObstruction::FOUNDATION_CHECK_FAIL_ERROR; if (cmpObstructionManager->TestUnitShape(filter, x, z, r, NULL)) return ICmpObstruction::FOUNDATION_CHECK_FAIL_OBSTRUCTS_FOUNDATION; // Test against terrain and static obstructions: u16 i, j; Pathfinding::NearestNavcell(x, z, i, j, m_MapSize*Pathfinding::NAVCELLS_PER_TILE, m_MapSize*Pathfinding::NAVCELLS_PER_TILE); if (!IS_PASSABLE(m_Grid->get(i, j), passClass)) return ICmpObstruction::FOUNDATION_CHECK_FAIL_TERRAIN_CLASS; // (Static obstructions will be redundantly tested against in both the // obstruction-shape test and navcell-passability test, which is slightly // inefficient but shouldn't affect behaviour) return ICmpObstruction::FOUNDATION_CHECK_SUCCESS; } ICmpObstruction::EFoundationCheck CCmpPathfinder::CheckBuildingPlacement(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t a, entity_pos_t w, entity_pos_t h, entity_id_t id, pass_class_t passClass) const { return CCmpPathfinder::CheckBuildingPlacement(filter, x, z, a, w, h, id, passClass, false); } ICmpObstruction::EFoundationCheck CCmpPathfinder::CheckBuildingPlacement(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t a, entity_pos_t w, entity_pos_t h, entity_id_t id, pass_class_t passClass, bool UNUSED(onlyCenterPoint)) const { // Check unit obstruction CmpPtr cmpObstructionManager(GetSystemEntity()); if (!cmpObstructionManager) return ICmpObstruction::FOUNDATION_CHECK_FAIL_ERROR; if (cmpObstructionManager->TestStaticShape(filter, x, z, a, w, h, NULL)) return ICmpObstruction::FOUNDATION_CHECK_FAIL_OBSTRUCTS_FOUNDATION; // Test against terrain: ICmpObstructionManager::ObstructionSquare square; CmpPtr cmpObstruction(GetSimContext(), id); if (!cmpObstruction || !cmpObstruction->GetObstructionSquare(square)) return ICmpObstruction::FOUNDATION_CHECK_FAIL_NO_OBSTRUCTION; entity_pos_t expand; const PathfinderPassability* passability = GetPassabilityFromMask(passClass); if (passability) expand = passability->m_Clearance; SimRasterize::Spans spans; SimRasterize::RasterizeRectWithClearance(spans, square, expand, Pathfinding::NAVCELL_SIZE); for (const SimRasterize::Span& span : spans) { i16 i0 = span.i0; i16 i1 = span.i1; i16 j = span.j; // Fail if any span extends outside the grid if (i0 < 0 || i1 > m_TerrainOnlyGrid->m_W || j < 0 || j > m_TerrainOnlyGrid->m_H) return ICmpObstruction::FOUNDATION_CHECK_FAIL_TERRAIN_CLASS; // Fail if any span includes an impassable tile for (i16 i = i0; i < i1; ++i) if (!IS_PASSABLE(m_TerrainOnlyGrid->get(i, j), passClass)) return ICmpObstruction::FOUNDATION_CHECK_FAIL_TERRAIN_CLASS; } return ICmpObstruction::FOUNDATION_CHECK_SUCCESS; } Index: ps/trunk/source/simulation2/components/CCmpPathfinder_Common.h =================================================================== --- ps/trunk/source/simulation2/components/CCmpPathfinder_Common.h (revision 23924) +++ ps/trunk/source/simulation2/components/CCmpPathfinder_Common.h (revision 23925) @@ -1,287 +1,289 @@ /* Copyright (C) 2020 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_CCMPPATHFINDER_COMMON #define INCLUDED_CCMPPATHFINDER_COMMON /** * @file * Declares CCmpPathfinder. Its implementation is mainly done in CCmpPathfinder.cpp, * but the short-range (vertex) pathfinding is done in CCmpPathfinder_Vertex.cpp. * This file provides common code needed for both files. * * The long-range pathfinding is done by a LongPathfinder object. */ #include "simulation2/system/Component.h" #include "ICmpPathfinder.h" #include "graphics/Overlay.h" #include "graphics/Terrain.h" #include "maths/MathUtil.h" #include "ps/CLogger.h" #include "renderer/TerrainOverlay.h" #include "simulation2/components/ICmpObstructionManager.h" #include "simulation2/helpers/Grid.h" class HierarchicalPathfinder; class LongPathfinder; class VertexPathfinder; class SceneCollector; class AtlasOverlay; #ifdef NDEBUG #define PATHFIND_DEBUG 0 #else #define PATHFIND_DEBUG 1 #endif /** * Implementation of ICmpPathfinder */ class CCmpPathfinder final : public ICmpPathfinder { protected: class PathfinderWorker { friend CCmpPathfinder; public: PathfinderWorker(); // Process path requests, checking if we should stop before each new one. void Work(const CCmpPathfinder& pathfinder); private: // Insert requests in m_[Long/Short]Requests depending on from. // This could be removed when we may use if-constexpr in CCmpPathfinder::PushRequestsToWorkers template void PushRequests(std::vector& from, ssize_t amount); // Stores our results, the main thread will fetch this. std::vector m_Results; std::vector m_LongRequests; std::vector m_ShortRequests; }; // Allow the workers to access our private variables friend class PathfinderWorker; public: static void ClassInit(CComponentManager& componentManager) { componentManager.SubscribeToMessageType(MT_Deserialized); componentManager.SubscribeToMessageType(MT_Update); componentManager.SubscribeToMessageType(MT_RenderSubmit); // for debug overlays componentManager.SubscribeToMessageType(MT_TerrainChanged); componentManager.SubscribeToMessageType(MT_WaterChanged); componentManager.SubscribeToMessageType(MT_ObstructionMapShapeChanged); componentManager.SubscribeToMessageType(MT_TurnStart); } ~CCmpPathfinder(); DEFAULT_COMPONENT_ALLOCATOR(Pathfinder) // Template state: std::map m_PassClassMasks; std::vector m_PassClasses; // Dynamic state: std::vector m_LongPathRequests; std::vector m_ShortPathRequests; u32 m_NextAsyncTicket; // Unique IDs for asynchronous path requests. u16 m_MaxSameTurnMoves; // Compute only this many paths when useMax is true in StartProcessingMoves. // Lazily-constructed dynamic state (not serialized): u16 m_MapSize; // tiles per side Grid* m_Grid; // terrain/passability information Grid* m_TerrainOnlyGrid; // same as m_Grid, but only with terrain, to avoid some recomputations // Keep clever updates in memory to avoid memory fragmentation from the grid. // This should be used only in UpdateGrid(), there is no guarantee the data is properly initialized anywhere else. GridUpdateInformation m_DirtinessInformation; // The data from clever updates is stored for the AI manager GridUpdateInformation m_AIPathfinderDirtinessInformation; bool m_TerrainDirty; std::unique_ptr m_VertexPathfinder; std::unique_ptr m_PathfinderHier; std::unique_ptr m_LongPathfinder; // Workers process pathing requests. std::vector m_Workers; AtlasOverlay* m_AtlasOverlay; static std::string GetSchema() { return ""; } virtual void Init(const CParamNode& paramNode); virtual void Deinit(); template void SerializeCommon(S& serialize); virtual void Serialize(ISerializer& serialize); virtual void Deserialize(const CParamNode& paramNode, IDeserializer& deserialize); virtual void HandleMessage(const CMessage& msg, bool global); virtual pass_class_t GetPassabilityClass(const std::string& name) const; virtual void GetPassabilityClasses(std::map& passClasses) const; virtual void GetPassabilityClasses( std::map& nonPathfindingPassClasses, std::map& pathfindingPassClasses) const; const PathfinderPassability* GetPassabilityFromMask(pass_class_t passClass) const; virtual entity_pos_t GetClearance(pass_class_t passClass) const { const PathfinderPassability* passability = GetPassabilityFromMask(passClass); if (!passability) return fixed::Zero(); return passability->m_Clearance; } virtual entity_pos_t GetMaximumClearance() const { entity_pos_t max = fixed::Zero(); for (const PathfinderPassability& passability : m_PassClasses) if (passability.m_Clearance > max) max = passability.m_Clearance; return max + Pathfinding::CLEARANCE_EXTENSION_RADIUS; } virtual const Grid& GetPassabilityGrid(); virtual const GridUpdateInformation& GetAIPathfinderDirtinessInformation() const { return m_AIPathfinderDirtinessInformation; } virtual void FlushAIPathfinderDirtinessInformation() { m_AIPathfinderDirtinessInformation.Clean(); } virtual Grid ComputeShoreGrid(bool expandOnWater = false); virtual void ComputePathImmediate(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass, WaypointPath& ret) const; virtual u32 ComputePathAsync(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass, entity_id_t notify); virtual WaypointPath ComputeShortPathImmediate(const ShortPathRequest& request) const; virtual u32 ComputeShortPathAsync(entity_pos_t x0, entity_pos_t z0, entity_pos_t clearance, entity_pos_t range, const PathGoal& goal, pass_class_t passClass, bool avoidMovingUnits, entity_id_t controller, entity_id_t notify); + virtual bool IsGoalReachable(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass); + virtual void SetDebugPath(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass); virtual void SetDebugOverlay(bool enabled); virtual void SetHierDebugOverlay(bool enabled); virtual void GetDebugData(u32& steps, double& time, Grid& grid) const; virtual void SetAtlasOverlay(bool enable, pass_class_t passClass = 0); virtual bool CheckMovement(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, entity_pos_t r, pass_class_t passClass) const; virtual ICmpObstruction::EFoundationCheck CheckUnitPlacement(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t r, pass_class_t passClass, bool onlyCenterPoint) const; virtual ICmpObstruction::EFoundationCheck CheckBuildingPlacement(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t a, entity_pos_t w, entity_pos_t h, entity_id_t id, pass_class_t passClass) const; virtual ICmpObstruction::EFoundationCheck CheckBuildingPlacement(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t a, entity_pos_t w, entity_pos_t h, entity_id_t id, pass_class_t passClass, bool onlyCenterPoint) const; virtual void FetchAsyncResultsAndSendMessages(); virtual void StartProcessingMoves(bool useMax); template std::vector GetMovesToProcess(std::vector& requests, bool useMax = false, size_t maxMoves = 0); template void PushRequestsToWorkers(std::vector& from); /** * Regenerates the grid based on the current obstruction list, if necessary */ virtual void UpdateGrid(); /** * Updates the terrain-only grid without updating the dirtiness informations. * Useful for fast passability updates in Atlas. */ void MinimalTerrainUpdate(int itile0, int jtile0, int itile1, int jtile1); /** * Regenerates the terrain-only grid. * Atlas doesn't need to have passability cells expanded. */ void TerrainUpdateHelper(bool expandPassability = true, int itile0 = -1, int jtile0 = -1, int itile1 = -1, int jtile1 = -1); void RenderSubmit(SceneCollector& collector); }; class AtlasOverlay : public TerrainTextureOverlay { public: const CCmpPathfinder* m_Pathfinder; pass_class_t m_PassClass; AtlasOverlay(const CCmpPathfinder* pathfinder, pass_class_t passClass) : TerrainTextureOverlay(Pathfinding::NAVCELLS_PER_TILE), m_Pathfinder(pathfinder), m_PassClass(passClass) { } virtual void BuildTextureRGBA(u8* data, size_t w, size_t h) { // Render navcell passability, based on the terrain-only grid u8* p = data; for (size_t j = 0; j < h; ++j) { for (size_t i = 0; i < w; ++i) { SColor4ub color(0, 0, 0, 0); if (!IS_PASSABLE(m_Pathfinder->m_TerrainOnlyGrid->get((int)i, (int)j), m_PassClass)) color = SColor4ub(255, 0, 0, 127); *p++ = color.R; *p++ = color.G; *p++ = color.B; *p++ = color.A; } } } }; #endif // INCLUDED_CCMPPATHFINDER_COMMON Index: ps/trunk/source/simulation2/components/CCmpUnitMotion.cpp =================================================================== --- ps/trunk/source/simulation2/components/CCmpUnitMotion.cpp (revision 23924) +++ ps/trunk/source/simulation2/components/CCmpUnitMotion.cpp (revision 23925) @@ -1,1531 +1,1585 @@ /* Copyright (C) 2020 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 "ICmpUnitMotion.h" #include "simulation2/components/ICmpObstruction.h" #include "simulation2/components/ICmpObstructionManager.h" #include "simulation2/components/ICmpOwnership.h" #include "simulation2/components/ICmpPosition.h" #include "simulation2/components/ICmpPathfinder.h" #include "simulation2/components/ICmpRangeManager.h" #include "simulation2/components/ICmpValueModificationManager.h" #include "simulation2/components/ICmpVisual.h" #include "simulation2/helpers/Geometry.h" #include "simulation2/helpers/Render.h" #include "simulation2/MessageTypes.h" #include "simulation2/serialization/SerializeTemplates.h" #include "graphics/Overlay.h" #include "graphics/Terrain.h" #include "maths/FixedVector2D.h" #include "ps/CLogger.h" #include "ps/Profile.h" #include "renderer/Scene.h" // For debugging; units will start going straight to the target // instead of calling the pathfinder #define DISABLE_PATHFINDER 0 /** * Min/Max range to restrict short path queries to. (Larger ranges are slower, * Min/Max range to restrict short path queries to. (Larger ranges are (much) slower, * smaller ranges might miss some legitimate routes around large obstacles.) */ static const entity_pos_t SHORT_PATH_MIN_SEARCH_RANGE = entity_pos_t::FromInt(TERRAIN_TILE_SIZE*3)/2; static const entity_pos_t SHORT_PATH_MAX_SEARCH_RANGE = entity_pos_t::FromInt(TERRAIN_TILE_SIZE*14); static const entity_pos_t SHORT_PATH_SEARCH_RANGE_INCREMENT = entity_pos_t::FromInt(TERRAIN_TILE_SIZE*2); /** * When using the short-pathfinder to rejoin a long-path waypoint, aim for a circle of this radius around the waypoint. */ static const entity_pos_t SHORT_PATH_LONG_WAYPOINT_RANGE = entity_pos_t::FromInt(TERRAIN_TILE_SIZE*1); /** * Minimum distance to goal for a long path request */ static const entity_pos_t LONG_PATH_MIN_DIST = entity_pos_t::FromInt(TERRAIN_TILE_SIZE*4); /** * If we are this close to our target entity/point, then think about heading * for it in a straight line instead of pathfinding. */ static const entity_pos_t DIRECT_PATH_RANGE = entity_pos_t::FromInt(TERRAIN_TILE_SIZE*4); /** * To avoid recomputing paths too often, have some leeway for target range checks * based on our distance to the target. Increase that incertainty by one navcell * for every this many tiles of distance. */ static const entity_pos_t TARGET_UNCERTAINTY_MULTIPLIER = entity_pos_t::FromInt(TERRAIN_TILE_SIZE*2); /** + * When following a known imperfect path (i.e. a path that won't take us in range of our goal + * we still recompute a new path every N turn to adapt to moving targets (for example, ships that must pickup + * units may easily end up in this state, they still need to adjust to moving units). + * This is rather arbitrary and mostly for simplicity & optimisation (a better recomputing algorithm + * would not need this). + * Keep in mind that MP turns are currently 500ms. + */ +static const u8 KNOWN_IMPERFECT_PATH_RESET_COUNTDOWN = 12; + +/** * When we fail more than this many path computations in a row, inform other components that the move will fail. * Experimentally, this number needs to be somewhat high or moving groups of units will lead to stuck units. * However, too high means units will look idle for a long time when they are failing to move. * TODO: if UnitMotion could send differentiated "unreachable" and "currently stuck" failing messages, * this could probably be lowered. * TODO: when unit pushing is implemented, this number can probably be lowered. */ static const u8 MAX_FAILED_PATH_COMPUTATIONS = 15; /** * If we have failed path computations this many times and ComputePathToGoal is called, * always run a long-path, to avoid getting stuck sometimes (see D1424). */ static const u8 MAX_FAILED_PATH_COMPUTATIONS_BEFORE_LONG_PATH = 3; static const CColor OVERLAY_COLOR_LONG_PATH(1, 1, 1, 1); static const CColor OVERLAY_COLOR_SHORT_PATH(1, 0, 0, 1); class CCmpUnitMotion : public ICmpUnitMotion { public: static void ClassInit(CComponentManager& componentManager) { componentManager.SubscribeToMessageType(MT_Update_MotionFormation); componentManager.SubscribeToMessageType(MT_Update_MotionUnit); componentManager.SubscribeToMessageType(MT_PathResult); componentManager.SubscribeToMessageType(MT_OwnershipChanged); componentManager.SubscribeToMessageType(MT_ValueModification); componentManager.SubscribeToMessageType(MT_Deserialized); } DEFAULT_COMPONENT_ALLOCATOR(UnitMotion) bool m_DebugOverlayEnabled; std::vector m_DebugOverlayLongPathLines; std::vector m_DebugOverlayShortPathLines; // Template state: bool m_FormationController; fixed m_TemplateWalkSpeed, m_TemplateRunMultiplier; pass_class_t m_PassClass; std::string m_PassClassName; // Dynamic state: entity_pos_t m_Clearance; // cached for efficiency fixed m_WalkSpeed, m_RunMultiplier; bool m_FacePointAfterMove; // Number of path computations that failed (in a row). // When this gets above MAX_FAILED_PATH_COMPUTATIONS, inform other components // that the move will likely fail. u8 m_FailedPathComputations = 0; - // If true, PathingUpdateNeeded returns false always. + // If > 0, PathingUpdateNeeded returns false always. // This exists because the goal may be unreachable to the short/long pathfinder. - // In such cases, we would compute inacceptable paths and PathingUpdateNeeded would trigger every turn. - // To avoid that, when we know the new path is imperfect, treat it as OK and follow it until the end. - // When reaching the end, we'll run through HandleObstructedMove and this will be reset. - bool m_FollowKnownImperfectPath = false; + // In such cases, we would compute inacceptable paths and PathingUpdateNeeded would trigger every turn, + // which would be quite bad for performance. + // To avoid that, when we know the new path is imperfect, treat it as OK and follow it anyways. + // When reaching the end, we'll go through HandleObstructedMove and reset regardless. + // To still recompute now and then (the target may be moving), this is a countdown decremented on each frame. + u8 m_FollowKnownImperfectPathCountdown = 0; struct Ticket { u32 m_Ticket = 0; // asynchronous request ID we're waiting for, or 0 if none enum Type { SHORT_PATH, LONG_PATH } m_Type = SHORT_PATH; // Pick some default value to avoid UB. void clear() { m_Ticket = 0; } } m_ExpectedPathTicket; struct MoveRequest { enum Type { NONE, POINT, ENTITY, OFFSET } m_Type = NONE; entity_id_t m_Entity = INVALID_ENTITY; CFixedVector2D m_Position; entity_pos_t m_MinRange, m_MaxRange; // For readability CFixedVector2D GetOffset() const { return m_Position; }; MoveRequest() = default; MoveRequest(CFixedVector2D pos, entity_pos_t minRange, entity_pos_t maxRange) : m_Type(POINT), m_Position(pos), m_MinRange(minRange), m_MaxRange(maxRange) {}; MoveRequest(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) : m_Type(ENTITY), m_Entity(target), m_MinRange(minRange), m_MaxRange(maxRange) {}; MoveRequest(entity_id_t target, CFixedVector2D offset) : m_Type(OFFSET), m_Entity(target), m_Position(offset) {}; } m_MoveRequest; // If the entity moves, it will do so at m_WalkSpeed * m_SpeedMultiplier. fixed m_SpeedMultiplier; // This caches the resulting speed from m_WalkSpeed * m_SpeedMultiplier for convenience. fixed m_Speed; // Current mean speed (over the last turn). fixed m_CurSpeed; // Currently active paths (storing waypoints in reverse order). // The last item in each path is the point we're currently heading towards. WaypointPath m_LongPath; WaypointPath m_ShortPath; static std::string GetSchema() { return "Provides the unit with the ability to move around the world by itself." "" "7.0" "default" "" "" "" "" "" "" "" "" "" "" "" "" "" "" ""; } virtual void Init(const CParamNode& paramNode) { m_FormationController = paramNode.GetChild("FormationController").ToBool(); m_FacePointAfterMove = true; m_WalkSpeed = m_TemplateWalkSpeed = m_Speed = paramNode.GetChild("WalkSpeed").ToFixed(); m_SpeedMultiplier = fixed::FromInt(1); m_CurSpeed = fixed::Zero(); m_RunMultiplier = m_TemplateRunMultiplier = fixed::FromInt(1); if (paramNode.GetChild("RunMultiplier").IsOk()) m_RunMultiplier = m_TemplateRunMultiplier = paramNode.GetChild("RunMultiplier").ToFixed(); CmpPtr cmpPathfinder(GetSystemEntity()); if (cmpPathfinder) { m_PassClassName = paramNode.GetChild("PassabilityClass").ToUTF8(); m_PassClass = cmpPathfinder->GetPassabilityClass(m_PassClassName); m_Clearance = cmpPathfinder->GetClearance(m_PassClass); CmpPtr cmpObstruction(GetEntityHandle()); if (cmpObstruction) cmpObstruction->SetUnitClearance(m_Clearance); } m_DebugOverlayEnabled = false; } virtual void Deinit() { } template void SerializeCommon(S& serialize) { serialize.StringASCII("pass class", m_PassClassName, 0, 64); serialize.NumberU32_Unbounded("ticket", m_ExpectedPathTicket.m_Ticket); SerializeU8_Enum()(serialize, "ticket type", m_ExpectedPathTicket.m_Type); - serialize.NumberU8("failed path computations", m_FailedPathComputations, 0, 255); - serialize.Bool("followknownimperfectpath", m_FollowKnownImperfectPath); + serialize.NumberU8_Unbounded("failed path computations", m_FailedPathComputations); + serialize.NumberU8_Unbounded("followknownimperfectpath", m_FollowKnownImperfectPathCountdown); SerializeU8_Enum()(serialize, "target type", m_MoveRequest.m_Type); serialize.NumberU32_Unbounded("target entity", m_MoveRequest.m_Entity); serialize.NumberFixed_Unbounded("target pos x", m_MoveRequest.m_Position.X); serialize.NumberFixed_Unbounded("target pos y", m_MoveRequest.m_Position.Y); serialize.NumberFixed_Unbounded("target min range", m_MoveRequest.m_MinRange); serialize.NumberFixed_Unbounded("target max range", m_MoveRequest.m_MaxRange); serialize.NumberFixed_Unbounded("speed multiplier", m_SpeedMultiplier); serialize.NumberFixed_Unbounded("current speed", m_CurSpeed); serialize.Bool("facePointAfterMove", m_FacePointAfterMove); SerializeVector()(serialize, "long path", m_LongPath.m_Waypoints); SerializeVector()(serialize, "short path", m_ShortPath.m_Waypoints); } virtual void Serialize(ISerializer& serialize) { SerializeCommon(serialize); } virtual void Deserialize(const CParamNode& paramNode, IDeserializer& deserialize) { Init(paramNode); SerializeCommon(deserialize); CmpPtr cmpPathfinder(GetSystemEntity()); if (cmpPathfinder) m_PassClass = cmpPathfinder->GetPassabilityClass(m_PassClassName); } virtual void HandleMessage(const CMessage& msg, bool UNUSED(global)) { switch (msg.GetType()) { case MT_Update_MotionFormation: { if (m_FormationController) { fixed dt = static_cast (msg).turnLength; Move(dt); } break; } case MT_Update_MotionUnit: { if (!m_FormationController) { fixed dt = static_cast (msg).turnLength; Move(dt); } break; } case MT_RenderSubmit: { PROFILE("UnitMotion::RenderSubmit"); const CMessageRenderSubmit& msgData = static_cast (msg); RenderSubmit(msgData.collector); break; } case MT_PathResult: { const CMessagePathResult& msgData = static_cast (msg); PathResult(msgData.ticket, msgData.path); break; } case MT_ValueModification: { const CMessageValueModification& msgData = static_cast (msg); if (msgData.component != L"UnitMotion") break; FALLTHROUGH; } case MT_OwnershipChanged: case MT_Deserialized: { CmpPtr cmpValueModificationManager(GetSystemEntity()); if (!cmpValueModificationManager) break; m_WalkSpeed = cmpValueModificationManager->ApplyModifications(L"UnitMotion/WalkSpeed", m_TemplateWalkSpeed, GetEntityId()); m_RunMultiplier = cmpValueModificationManager->ApplyModifications(L"UnitMotion/RunMultiplier", m_TemplateRunMultiplier, GetEntityId()); // For MT_Deserialize compute m_Speed from the serialized m_SpeedMultiplier. // For MT_ValueModification and MT_OwnershipChanged, adjust m_SpeedMultiplier if needed // (in case then new m_RunMultiplier value is lower than the old). SetSpeedMultiplier(m_SpeedMultiplier); break; } } } void UpdateMessageSubscriptions() { bool needRender = m_DebugOverlayEnabled; GetSimContext().GetComponentManager().DynamicSubscriptionNonsync(MT_RenderSubmit, this, needRender); } virtual bool IsMoveRequested() const { return m_MoveRequest.m_Type != MoveRequest::NONE; } virtual fixed GetSpeedMultiplier() const { return m_SpeedMultiplier; } virtual void SetSpeedMultiplier(fixed multiplier) { m_SpeedMultiplier = std::min(multiplier, m_RunMultiplier); m_Speed = m_SpeedMultiplier.Multiply(GetWalkSpeed()); } virtual fixed GetSpeed() const { return m_Speed; } virtual fixed GetWalkSpeed() const { return m_WalkSpeed; } virtual fixed GetRunMultiplier() const { return m_RunMultiplier; } virtual pass_class_t GetPassabilityClass() const { return m_PassClass; } virtual std::string GetPassabilityClassName() const { return m_PassClassName; } virtual void SetPassabilityClassName(const std::string& passClassName) { m_PassClassName = passClassName; CmpPtr cmpPathfinder(GetSystemEntity()); if (cmpPathfinder) m_PassClass = cmpPathfinder->GetPassabilityClass(passClassName); } virtual fixed GetCurrentSpeed() const { return m_CurSpeed; } virtual void SetFacePointAfterMove(bool facePointAfterMove) { m_FacePointAfterMove = facePointAfterMove; } virtual bool GetFacePointAfterMove() const { return m_FacePointAfterMove; } virtual void SetDebugOverlay(bool enabled) { m_DebugOverlayEnabled = enabled; UpdateMessageSubscriptions(); } virtual bool MoveToPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t minRange, entity_pos_t maxRange) { return MoveTo(MoveRequest(CFixedVector2D(x, z), minRange, maxRange)); } virtual bool MoveToTargetRange(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) { return MoveTo(MoveRequest(target, minRange, maxRange)); } virtual void MoveToFormationOffset(entity_id_t target, entity_pos_t x, entity_pos_t z) { MoveTo(MoveRequest(target, CFixedVector2D(x, z))); } + virtual bool IsTargetRangeReachable(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange); + virtual void FaceTowardsPoint(entity_pos_t x, entity_pos_t z); /** * Clears the current MoveRequest - the unit will stop and no longer try and move. * This should never be called from UnitMotion, since MoveToX orders are given * by other components - these components should also decide when to stop. */ virtual void StopMoving() { if (m_FacePointAfterMove) { CmpPtr cmpPosition(GetEntityHandle()); if (cmpPosition && cmpPosition->IsInWorld()) { CFixedVector2D targetPos; if (ComputeTargetPosition(targetPos)) FaceTowardsPointFromPos(cmpPosition->GetPosition2D(), targetPos.X, targetPos.Y); } } m_MoveRequest = MoveRequest(); m_ExpectedPathTicket.clear(); m_LongPath.m_Waypoints.clear(); m_ShortPath.m_Waypoints.clear(); } virtual entity_pos_t GetUnitClearance() const { return m_Clearance; } private: bool ShouldAvoidMovingUnits() const { return !m_FormationController; } bool IsFormationMember() const { // TODO: this really shouldn't be what we are checking for. return m_MoveRequest.m_Type == MoveRequest::OFFSET; } bool IsFormationControllerMoving() const { CmpPtr cmpControllerMotion(GetSimContext(), m_MoveRequest.m_Entity); return cmpControllerMotion && cmpControllerMotion->IsMoveRequested(); } entity_id_t GetGroup() const { return IsFormationMember() ? m_MoveRequest.m_Entity : GetEntityId(); } /** * Warns other components that our current movement will likely fail (e.g. we won't be able to reach our target) * This should only be called before the actual movement in a given turn, or units might both move and try to do things * on the same turn, leading to gliding units. */ void MoveFailed() { // Don't notify if we are a formation member in a moving formation - we can occasionally be stuck for a long time // if our current offset is unreachable, but we don't want to end up stuck. // (If the formation controller has stopped moving however, we can safely message). if (IsFormationMember() && IsFormationControllerMoving()) return; CMessageMotionUpdate msg(CMessageMotionUpdate::LIKELY_FAILURE); GetSimContext().GetComponentManager().PostMessage(GetEntityId(), msg); } /** * Warns other components that our current movement is likely over (i.e. we probably reached our destination) * This should only be called before the actual movement in a given turn, or units might both move and try to do things * on the same turn, leading to gliding units. */ void MoveSucceeded() { // Don't notify if we are a formation member in a moving formation - we can occasionally be stuck for a long time // if our current offset is unreachable, but we don't want to end up stuck. // (If the formation controller has stopped moving however, we can safely message). if (IsFormationMember() && IsFormationControllerMoving()) return; CMessageMotionUpdate msg(CMessageMotionUpdate::LIKELY_SUCCESS); GetSimContext().GetComponentManager().PostMessage(GetEntityId(), msg); } /** * Warns other components that our current movement was obstructed (i.e. we failed to move this turn). * This should only be called before the actual movement in a given turn, or units might both move and try to do things * on the same turn, leading to gliding units. */ void MoveObstructed() { // Don't notify if we are a formation member in a moving formation - we can occasionally be stuck for a long time // if our current offset is unreachable, but we don't want to end up stuck. // (If the formation controller has stopped moving however, we can safely message). if (IsFormationMember() && IsFormationControllerMoving()) return; CMessageMotionUpdate msg(CMessageMotionUpdate::OBSTRUCTED); GetSimContext().GetComponentManager().PostMessage(GetEntityId(), msg); } /** * Increment the number of failed path computations and notify other components if required. + * @returns true if the failure was notified, false otherwise. */ - void IncrementFailedPathComputationAndMaybeNotify() + bool IncrementFailedPathComputationAndMaybeNotify() { m_FailedPathComputations++; if (m_FailedPathComputations >= MAX_FAILED_PATH_COMPUTATIONS) { MoveFailed(); m_FailedPathComputations = 0; + return true; } + return false; } /** * If path would take us farther away from the goal than pos currently is, return false, else return true. */ bool RejectFartherPaths(const PathGoal& goal, const WaypointPath& path, const CFixedVector2D& pos) const; /** * If there are 2 waypoints of more remaining in longPath, return SHORT_PATH_LONG_WAYPOINT_RANGE. * Otherwise the pathing should be exact. */ entity_pos_t ShortPathWaypointRange(const WaypointPath& longPath) const { return longPath.m_Waypoints.size() >= 2 ? SHORT_PATH_LONG_WAYPOINT_RANGE : entity_pos_t::Zero(); } bool InShortPathRange(const PathGoal& goal, const CFixedVector2D& pos) const { return goal.DistanceToPoint(pos) < LONG_PATH_MIN_DIST; } /** * Handle the result of an asynchronous path query. */ void PathResult(u32 ticket, const WaypointPath& path); /** * Do the per-turn movement and other updates. */ void Move(fixed dt); /** * Returns true if we are possibly at our destination. * Since the concept of being at destination is dependent on why the move was requested, * UnitMotion can only ever hint about this, hence the conditional tone. */ bool PossiblyAtDestination() const; /** * Process the move the unit will do this turn. * This does not send actually change the position. * @returns true if the move was obstructed. */ bool PerformMove(fixed dt, WaypointPath& shortPath, WaypointPath& longPath, CFixedVector2D& pos) const; /** * Update other components on our speed. * (For performance, this should try to avoid sending messages). */ void UpdateMovementState(entity_pos_t speed); /** * React if our move was obstructed. * @returns true if the obstruction required handling, false otherwise. */ bool HandleObstructedMove(); /** * Returns true if the target position is valid. False otherwise. * (this may indicate that the target is e.g. out of the world/dead). * NB: for code-writing convenience, if we have no target, this returns true. */ bool TargetHasValidPosition(const MoveRequest& moveRequest) const; bool TargetHasValidPosition() const { return TargetHasValidPosition(m_MoveRequest); } /** * Computes the current location of our target entity (plus offset). * Returns false if no target entity or no valid position. */ bool ComputeTargetPosition(CFixedVector2D& out, const MoveRequest& moveRequest) const; bool ComputeTargetPosition(CFixedVector2D& out) const { return ComputeTargetPosition(out, m_MoveRequest); } /** * Attempts to replace the current path with a straight line to the target, * if it's close enough and the route is not obstructed. */ bool TryGoingStraightToTarget(const CFixedVector2D& from); /** * Returns whether our we need to recompute a path to reach our target. */ bool PathingUpdateNeeded(const CFixedVector2D& from) const; /** * Rotate to face towards the target point, given the current pos */ void FaceTowardsPointFromPos(const CFixedVector2D& pos, entity_pos_t x, entity_pos_t z); /** * Returns an appropriate obstruction filter for use with path requests. */ ControlGroupMovementObstructionFilter GetObstructionFilter() const; /** * Decide whether to approximate the given range from a square target as a circle, * rather than as a square. */ bool ShouldTreatTargetAsCircle(entity_pos_t range, entity_pos_t circleRadius) const; /** * Create a PathGoal from a move request. * @returns true if the goal was successfully created. */ bool ComputeGoal(PathGoal& out, const MoveRequest& moveRequest) const; /** * Compute a path to the given goal from the given position. * Might go in a straight line immediately, or might start an asynchronous path request. */ void ComputePathToGoal(const CFixedVector2D& from, const PathGoal& goal); /** * Start an asynchronous long path query. */ void RequestLongPath(const CFixedVector2D& from, const PathGoal& goal); /** * Start an asynchronous short path query. */ void RequestShortPath(const CFixedVector2D& from, const PathGoal& goal, bool avoidMovingUnits); /** * General handler for MoveTo interface functions. */ bool MoveTo(MoveRequest request); /** * Convert a path into a renderable list of lines */ void RenderPath(const WaypointPath& path, std::vector& lines, CColor color); void RenderSubmit(SceneCollector& collector); }; REGISTER_COMPONENT_TYPE(UnitMotion) bool CCmpUnitMotion::RejectFartherPaths(const PathGoal& goal, const WaypointPath& path, const CFixedVector2D& pos) const { if (path.m_Waypoints.empty()) return false; // Reject the new path if it does not lead us closer to the target's position. if (goal.DistanceToPoint(pos) <= goal.DistanceToPoint(CFixedVector2D(path.m_Waypoints.front().x, path.m_Waypoints.front().z))) return true; return false; } void CCmpUnitMotion::PathResult(u32 ticket, const WaypointPath& path) { // Ignore obsolete path requests if (ticket != m_ExpectedPathTicket.m_Ticket || m_MoveRequest.m_Type == MoveRequest::NONE) return; Ticket::Type ticketType = m_ExpectedPathTicket.m_Type; m_ExpectedPathTicket.clear(); // Check that we are still able to do something with that path CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) { // We will probably fail to move so inform components but keep on trying anyways. MoveFailed(); return; } CFixedVector2D pos = cmpPosition->GetPosition2D(); PathGoal goal; // If we can't compute a goal, we'll fail in the next Move() call so do nothing special. if (!ComputeGoal(goal, m_MoveRequest)) return; if (ticketType == Ticket::LONG_PATH) { if (RejectFartherPaths(goal, path, pos)) { IncrementFailedPathComputationAndMaybeNotify(); return; } m_LongPath = path; - m_FollowKnownImperfectPath = false; + m_FollowKnownImperfectPathCountdown = 0; // If there's no waypoints then we couldn't get near the target. // Sort of hack: Just try going directly to the goal point instead // (via the short pathfinder over the next turns), so if we're stuck and the user clicks // close enough to the unit then we can probably get unstuck // NB: this relies on HandleObstructedMove requesting short paths if we still have long waypoints. if (m_LongPath.m_Waypoints.empty()) { IncrementFailedPathComputationAndMaybeNotify(); CFixedVector2D targetPos; if (ComputeTargetPosition(targetPos)) m_LongPath.m_Waypoints.emplace_back(Waypoint{ targetPos.X, targetPos.Y }); } // If this new path won't put us in range, it's highly likely that we are going somewhere unreachable. // This means we will try to recompute the path every turn. // To avoid this, act as if our current path leads us to the correct destination. // (we will still fail the move when we arrive to the best possible position, and if we were blocked by // an obstruction and it goes away we will notice when getting there as having no waypoint goes through // HandleObstructedMove, so this is safe). - // TODO: For now, we won't warn components straight away as that could lead to units idling earlier than expected, - // but it should be done someday when the message can differentiate between different failure causes. else if (PathingUpdateNeeded(pos)) - m_FollowKnownImperfectPath = true; + { + // Inform other components early, as they might have better behaviour than waiting for the path to carry out. + // Send OBSTRUCTED at first - moveFailed is likely to trigger path recomputation and we might end up + // recomputing too often for nothing. + if (!IncrementFailedPathComputationAndMaybeNotify()) + { + CMessageMotionUpdate msg(CMessageMotionUpdate::OBSTRUCTED); + GetSimContext().GetComponentManager().PostMessage(GetEntityId(), msg); + } + m_FollowKnownImperfectPathCountdown = KNOWN_IMPERFECT_PATH_RESET_COUNTDOWN; + } return; } // Reject new short paths if they were aiming at the goal directly (i.e. no long waypoints still exists). if (m_LongPath.m_Waypoints.empty() && RejectFartherPaths(goal, path, pos)) { IncrementFailedPathComputationAndMaybeNotify(); return; } m_ShortPath = path; - m_FollowKnownImperfectPath = false; + m_FollowKnownImperfectPathCountdown = 0; if (!m_ShortPath.m_Waypoints.empty()) { if (PathingUpdateNeeded(pos)) - m_FollowKnownImperfectPath = true; + { + // Inform other components early, as they might have better behaviour than waiting for the path to carry out. + // Send OBSTRUCTED at first - moveFailed is likely to trigger path recomputation and we might end up + // recomputing too often for nothing. + if (!IncrementFailedPathComputationAndMaybeNotify()) + { + CMessageMotionUpdate msg(CMessageMotionUpdate::OBSTRUCTED); + GetSimContext().GetComponentManager().PostMessage(GetEntityId(), msg); + } + m_FollowKnownImperfectPathCountdown = KNOWN_IMPERFECT_PATH_RESET_COUNTDOWN; + } return; } if (m_FailedPathComputations >= 1) // Inform other components - we might be ordered to stop, and computeGoal will then fail and return early. MoveObstructed(); IncrementFailedPathComputationAndMaybeNotify(); // If there's no waypoints then we couldn't get near the target // If we're globally following a long path, try to remove the next waypoint, // it might be obstructed (e.g. by idle entities which the long-range pathfinder doesn't see). if (!m_LongPath.m_Waypoints.empty()) { m_LongPath.m_Waypoints.pop_back(); if (!m_LongPath.m_Waypoints.empty()) { // Get close enough - this will likely help the short path efficiency, and if we end up taking a wrong way // we'll easily be able to revert it using a long path. PathGoal goal = { PathGoal::CIRCLE, m_LongPath.m_Waypoints.back().x, m_LongPath.m_Waypoints.back().z, ShortPathWaypointRange(m_LongPath) }; RequestShortPath(pos, goal, true); return; } } ComputePathToGoal(pos, goal); } void CCmpUnitMotion::Move(fixed dt) { PROFILE("Move"); // If we were idle and will still be, we can return. // TODO: this will need to be removed if pushing is implemented. if (m_CurSpeed == fixed::Zero() && m_MoveRequest.m_Type == MoveRequest::NONE) return; if (PossiblyAtDestination()) MoveSucceeded(); else if (!TargetHasValidPosition()) { // Scrap waypoints - we don't know where to go. // If the move request remains unchanged and the target again has a valid position later on, // moving will be resumed. // Units may want to move to move to the target's last known position, // but that should be decided by UnitAI (handling MoveFailed), not UnitMotion. m_LongPath.m_Waypoints.clear(); m_ShortPath.m_Waypoints.clear(); MoveFailed(); } CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return; CFixedVector2D initialPos = cmpPosition->GetPosition2D(); // Keep track of the current unit's position during the update CFixedVector2D pos = initialPos; // If we're chasing a potentially-moving unit and are currently close // enough to its current position, and we can head in a straight line // to it, then throw away our current path and go straight to it bool wentStraight = TryGoingStraightToTarget(initialPos); bool wasObstructed = PerformMove(dt, m_ShortPath, m_LongPath, pos); // Update our speed over this turn so that the visual actor shows the correct animation. if (pos == initialPos) UpdateMovementState(fixed::Zero()); else { // Update the Position component after our movement (if we actually moved anywhere) CFixedVector2D offset = pos - initialPos; // Face towards the target entity_angle_t angle = atan2_approx(offset.X, offset.Y); cmpPosition->MoveAndTurnTo(pos.X,pos.Y, angle); // Calculate the mean speed over this past turn. UpdateMovementState(offset.Length() / dt); } if (wasObstructed && HandleObstructedMove()) return; else if (!wasObstructed) m_FailedPathComputations = 0; // We may need to recompute our path sometimes (e.g. if our target moves). // Since we request paths asynchronously anyways, this does not need to be done before moving. if (!wentStraight && PathingUpdateNeeded(pos)) { PathGoal goal; if (ComputeGoal(goal, m_MoveRequest)) ComputePathToGoal(pos, goal); } + else if (m_FollowKnownImperfectPathCountdown > 0) + --m_FollowKnownImperfectPathCountdown; } bool CCmpUnitMotion::PossiblyAtDestination() const { if (m_MoveRequest.m_Type == MoveRequest::NONE) return false; CmpPtr cmpObstructionManager(GetSystemEntity()); ENSURE(cmpObstructionManager); if (m_MoveRequest.m_Type == MoveRequest::POINT) return cmpObstructionManager->IsInPointRange(GetEntityId(), m_MoveRequest.m_Position.X, m_MoveRequest.m_Position.Y, m_MoveRequest.m_MinRange, m_MoveRequest.m_MaxRange, false); if (m_MoveRequest.m_Type == MoveRequest::ENTITY) return cmpObstructionManager->IsInTargetRange(GetEntityId(), m_MoveRequest.m_Entity, m_MoveRequest.m_MinRange, m_MoveRequest.m_MaxRange, false); if (m_MoveRequest.m_Type == MoveRequest::OFFSET) { CmpPtr cmpControllerMotion(GetSimContext(), m_MoveRequest.m_Entity); if (cmpControllerMotion && cmpControllerMotion->IsMoveRequested()) return false; CFixedVector2D targetPos; ComputeTargetPosition(targetPos); CmpPtr cmpPosition(GetEntityHandle()); return cmpObstructionManager->IsInPointRange(GetEntityId(), targetPos.X, targetPos.Y, m_MoveRequest.m_MinRange, m_MoveRequest.m_MaxRange, false); } return false; } bool CCmpUnitMotion::PerformMove(fixed dt, WaypointPath& shortPath, WaypointPath& longPath, CFixedVector2D& pos) const { // If there are no waypoint, behave as though we were obstructed and let HandleObstructedMove handle it. if (shortPath.m_Waypoints.empty() && longPath.m_Waypoints.empty()) return true; // TODO: there's some asymmetry here when units look at other // units' positions - the result will depend on the order of execution. // Maybe we should split the updates into multiple phases to minimise // that problem. CmpPtr cmpPathfinder(GetSystemEntity()); ENSURE(cmpPathfinder); fixed basicSpeed = m_Speed; // If in formation, run to keep up; otherwise just walk if (IsFormationMember()) basicSpeed = m_Speed.Multiply(m_RunMultiplier); // Find the speed factor of the underlying terrain // (We only care about the tile we start on - it doesn't matter if we're moving // partially onto a much slower/faster tile) // TODO: Terrain-dependent speeds are not currently supported fixed terrainSpeed = fixed::FromInt(1); fixed maxSpeed = basicSpeed.Multiply(terrainSpeed); // We want to move (at most) maxSpeed*dt units from pos towards the next waypoint fixed timeLeft = dt; fixed zero = fixed::Zero(); while (timeLeft > zero) { // If we ran out of path, we have to stop if (shortPath.m_Waypoints.empty() && longPath.m_Waypoints.empty()) break; CFixedVector2D target; if (shortPath.m_Waypoints.empty()) target = CFixedVector2D(longPath.m_Waypoints.back().x, longPath.m_Waypoints.back().z); else target = CFixedVector2D(shortPath.m_Waypoints.back().x, shortPath.m_Waypoints.back().z); CFixedVector2D offset = target - pos; // Work out how far we can travel in timeLeft fixed maxdist = maxSpeed.Multiply(timeLeft); // If the target is close, we can move there directly fixed offsetLength = offset.Length(); if (offsetLength <= maxdist) { if (cmpPathfinder->CheckMovement(GetObstructionFilter(), pos.X, pos.Y, target.X, target.Y, m_Clearance, m_PassClass)) { pos = target; // Spend the rest of the time heading towards the next waypoint timeLeft = (maxdist - offsetLength) / maxSpeed; if (shortPath.m_Waypoints.empty()) longPath.m_Waypoints.pop_back(); else shortPath.m_Waypoints.pop_back(); continue; } else { // Error - path was obstructed return true; } } else { // Not close enough, so just move in the right direction offset.Normalize(maxdist); target = pos + offset; if (cmpPathfinder->CheckMovement(GetObstructionFilter(), pos.X, pos.Y, target.X, target.Y, m_Clearance, m_PassClass)) pos = target; else return true; break; } } return false; } void CCmpUnitMotion::UpdateMovementState(entity_pos_t speed) { CmpPtr cmpObstruction(GetEntityHandle()); CmpPtr cmpVisual(GetEntityHandle()); // Moved last turn, didn't this turn. if (speed == fixed::Zero() && m_CurSpeed > fixed::Zero()) { if (cmpObstruction) cmpObstruction->SetMovingFlag(false); if (cmpVisual) cmpVisual->SelectMovementAnimation("idle", fixed::FromInt(1)); } // Moved this turn, didn't last turn else if (speed > fixed::Zero() && m_CurSpeed == fixed::Zero()) { if (cmpObstruction) cmpObstruction->SetMovingFlag(true); if (cmpVisual) cmpVisual->SelectMovementAnimation(m_Speed > m_WalkSpeed ? "run" : "walk", m_Speed); } // Speed change, update the visual actor if necessary. else if (speed != m_CurSpeed && cmpVisual) cmpVisual->SelectMovementAnimation(m_Speed > m_WalkSpeed ? "run" : "walk", m_Speed); m_CurSpeed = speed; } bool CCmpUnitMotion::HandleObstructedMove() { CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return false; if (m_FailedPathComputations >= 1) // Inform other components - we might be ordered to stop, and computeGoal will then fail and return early. MoveObstructed(); CFixedVector2D pos = cmpPosition->GetPosition2D(); // Oops, we hit something (very likely another unit). PathGoal goal; if (!ComputeGoal(goal, m_MoveRequest)) return false; if (!InShortPathRange(goal, pos)) { // If we still have long waypoints, try and compute a short path to our next long waypoint. // Assume the next waypoint is impassable and pop it. This helps unstuck entities in some cases, and we'll just // end up recomputing a long path if we pop all of them, so it's safe. if (m_LongPath.m_Waypoints.size() >= 1) m_LongPath.m_Waypoints.pop_back(); if (!m_LongPath.m_Waypoints.empty()) { // Get close enough - this will likely help the short path efficiency, and if we end up taking a wrong way // we'll easily be able to revert it using a long path. PathGoal goal = { PathGoal::CIRCLE, m_LongPath.m_Waypoints.back().x, m_LongPath.m_Waypoints.back().z, ShortPathWaypointRange(m_LongPath) }; RequestShortPath(pos, goal, true); return true; } } // Else, just entirely recompute. This will ensure we occasionally run a long path so avoid getting stuck // in the short pathfinder, which can happen when an entity is right ober an obstruction's edge. ComputePathToGoal(pos, goal); // potential TODO: We could switch the short-range pathfinder for something else entirely. return true; } bool CCmpUnitMotion::TargetHasValidPosition(const MoveRequest& moveRequest) const { if (moveRequest.m_Type != MoveRequest::ENTITY) return true; CmpPtr cmpPosition(GetSimContext(), moveRequest.m_Entity); return cmpPosition && cmpPosition->IsInWorld(); } bool CCmpUnitMotion::ComputeTargetPosition(CFixedVector2D& out, const MoveRequest& moveRequest) const { if (moveRequest.m_Type == MoveRequest::POINT) { out = moveRequest.m_Position; return true; } CmpPtr cmpTargetPosition(GetSimContext(), moveRequest.m_Entity); if (!cmpTargetPosition || !cmpTargetPosition->IsInWorld()) return false; if (moveRequest.m_Type == MoveRequest::OFFSET) { // There is an offset, so compute it relative to orientation entity_angle_t angle = cmpTargetPosition->GetRotation().Y; CFixedVector2D offset = moveRequest.GetOffset().Rotate(angle); out = cmpTargetPosition->GetPosition2D() + offset; } else { out = cmpTargetPosition->GetPosition2D(); // If the target is moving, we might never get in range if we just try to reach its current position, // so we have to try and move to a position where we will be in-range, including their movement. // Since we request paths asynchronously a the end of our turn and the order in which two units move is uncertain, // we need to account for twice the movement speed to be sure that we're targeting the correct point. // TODO: be cleverer about this. It fixes fleeing nicely currently, but orthogonal movement should be considered, // and the overall logic could be improved upon. CmpPtr cmpUnitMotion(GetSimContext(), moveRequest.m_Entity); if (cmpUnitMotion && cmpUnitMotion->IsMoveRequested()) { CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return true; // Still return true since we don't need a position for the target to have one. CFixedVector2D tempPos = out + (out - cmpTargetPosition->GetPreviousPosition2D()) * 2; // Check if we anticipate the target to go through us, in which case we shouldn't anticipate // (or e.g. units fleeing might suddenly turn around towards their attacker). if ((out - cmpPosition->GetPosition2D()).Dot(tempPos - cmpPosition->GetPosition2D()) >= fixed::Zero()) out = tempPos; } } return true; } bool CCmpUnitMotion::TryGoingStraightToTarget(const CFixedVector2D& from) { CFixedVector2D targetPos; if (!ComputeTargetPosition(targetPos)) return false; // Fail if the target is too far away if ((targetPos - from).CompareLength(DIRECT_PATH_RANGE) > 0) return false; CmpPtr cmpPathfinder(GetSystemEntity()); if (!cmpPathfinder) return false; // Move the goal to match the target entity's new position PathGoal goal; if (!ComputeGoal(goal, m_MoveRequest)) return false; goal.x = targetPos.X; goal.z = targetPos.Y; // (we ignore changes to the target's rotation, since only buildings are // square and buildings don't move) // Find the point on the goal shape that we should head towards CFixedVector2D goalPos = goal.NearestPointOnGoal(from); // Check if there's any collisions on that route. // For entity goals, skip only the specific obstruction tag or with e.g. walls we might ignore too many entities. ICmpObstructionManager::tag_t specificIgnore; if (m_MoveRequest.m_Type == MoveRequest::ENTITY) { CmpPtr cmpTargetObstruction(GetSimContext(), m_MoveRequest.m_Entity); if (cmpTargetObstruction) specificIgnore = cmpTargetObstruction->GetObstruction(); } if (specificIgnore.valid()) { if (!cmpPathfinder->CheckMovement(SkipTagObstructionFilter(specificIgnore), from.X, from.Y, goalPos.X, goalPos.Y, m_Clearance, m_PassClass)) return false; } else if (!cmpPathfinder->CheckMovement(GetObstructionFilter(), from.X, from.Y, goalPos.X, goalPos.Y, m_Clearance, m_PassClass)) return false; // That route is okay, so update our path m_LongPath.m_Waypoints.clear(); m_ShortPath.m_Waypoints.clear(); m_ShortPath.m_Waypoints.emplace_back(Waypoint{ goalPos.X, goalPos.Y }); return true; } bool CCmpUnitMotion::PathingUpdateNeeded(const CFixedVector2D& from) const { if (m_MoveRequest.m_Type == MoveRequest::NONE) return false; CFixedVector2D targetPos; if (!ComputeTargetPosition(targetPos)) return false; - if (m_FollowKnownImperfectPath) + if (m_FollowKnownImperfectPathCountdown > 0) return false; if (PossiblyAtDestination()) return false; // Get the obstruction shape and translate it where we estimate the target to be. ICmpObstructionManager::ObstructionSquare estimatedTargetShape; if (m_MoveRequest.m_Type == MoveRequest::ENTITY) { CmpPtr cmpTargetObstruction(GetSimContext(), m_MoveRequest.m_Entity); if (cmpTargetObstruction) cmpTargetObstruction->GetObstructionSquare(estimatedTargetShape); } estimatedTargetShape.x = targetPos.X; estimatedTargetShape.z = targetPos.Y; CmpPtr cmpObstruction(GetEntityHandle()); ICmpObstructionManager::ObstructionSquare shape; if (cmpObstruction) cmpObstruction->GetObstructionSquare(shape); // Translate our own obstruction shape to our last waypoint or our current position, lacking that. if (m_LongPath.m_Waypoints.empty() && m_ShortPath.m_Waypoints.empty()) { shape.x = from.X; shape.z = from.Y; } else { const Waypoint& lastWaypoint = m_LongPath.m_Waypoints.empty() ? m_ShortPath.m_Waypoints.front() : m_LongPath.m_Waypoints.front(); shape.x = lastWaypoint.x; shape.z = lastWaypoint.z; } CmpPtr cmpObstructionManager(GetSystemEntity()); ENSURE(cmpObstructionManager); // Increase the ranges with distance, to avoid recomputing every turn against units that are moving and far-away for example. entity_pos_t distance = (from - CFixedVector2D(estimatedTargetShape.x, estimatedTargetShape.z)).Length(); // When in straight-path distance, we want perfect detection. distance = std::max(distance - DIRECT_PATH_RANGE, entity_pos_t::Zero()); // TODO: it could be worth computing this based on time to collision instead of linear distance. entity_pos_t minRange = std::max(m_MoveRequest.m_MinRange - distance / TARGET_UNCERTAINTY_MULTIPLIER, entity_pos_t::Zero()); entity_pos_t maxRange = m_MoveRequest.m_MaxRange < entity_pos_t::Zero() ? m_MoveRequest.m_MaxRange : m_MoveRequest.m_MaxRange + distance / TARGET_UNCERTAINTY_MULTIPLIER; if (cmpObstructionManager->AreShapesInRange(shape, estimatedTargetShape, minRange, maxRange, false)) return false; return true; } void CCmpUnitMotion::FaceTowardsPoint(entity_pos_t x, entity_pos_t z) { CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return; CFixedVector2D pos = cmpPosition->GetPosition2D(); FaceTowardsPointFromPos(pos, x, z); } void CCmpUnitMotion::FaceTowardsPointFromPos(const CFixedVector2D& pos, entity_pos_t x, entity_pos_t z) { CFixedVector2D target(x, z); CFixedVector2D offset = target - pos; if (!offset.IsZero()) { entity_angle_t angle = atan2_approx(offset.X, offset.Y); CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition) return; cmpPosition->TurnTo(angle); } } ControlGroupMovementObstructionFilter CCmpUnitMotion::GetObstructionFilter() const { return ControlGroupMovementObstructionFilter(ShouldAvoidMovingUnits(), GetGroup()); } // The pathfinder cannot go to "rounded rectangles" goals, which are what happens with square targets and a non-null range. // Depending on what the best approximation is, we either pretend the target is a circle or a square. // One needs to be careful that the approximated geometry will be in the range. bool CCmpUnitMotion::ShouldTreatTargetAsCircle(entity_pos_t range, entity_pos_t circleRadius) const { // Given a square, plus a target range we should reach, the shape at that distance // is a round-cornered square which we can approximate as either a circle or as a square. // Previously, we used the shape that minimized the worst-case error. // However that is unsage in some situations. So let's be less clever and // just check if our range is at least three times bigger than the circleradius return (range > circleRadius*3); } bool CCmpUnitMotion::ComputeGoal(PathGoal& out, const MoveRequest& moveRequest) const { if (moveRequest.m_Type == MoveRequest::NONE) return false; CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return false; CFixedVector2D pos = cmpPosition->GetPosition2D(); CFixedVector2D targetPosition; if (!ComputeTargetPosition(targetPosition, moveRequest)) return false; ICmpObstructionManager::ObstructionSquare targetObstruction; if (moveRequest.m_Type == MoveRequest::ENTITY) { CmpPtr cmpTargetObstruction(GetSimContext(), moveRequest.m_Entity); if (cmpTargetObstruction) cmpTargetObstruction->GetObstructionSquare(targetObstruction); } targetObstruction.x = targetPosition.X; targetObstruction.z = targetPosition.Y; ICmpObstructionManager::ObstructionSquare obstruction; CmpPtr cmpObstruction(GetEntityHandle()); if (cmpObstruction) cmpObstruction->GetObstructionSquare(obstruction); else { obstruction.x = pos.X; obstruction.z = pos.Y; } CmpPtr cmpObstructionManager(GetSystemEntity()); ENSURE(cmpObstructionManager); entity_pos_t distance = cmpObstructionManager->DistanceBetweenShapes(obstruction, targetObstruction); out.x = targetObstruction.x; out.z = targetObstruction.z; out.hw = targetObstruction.hw; out.hh = targetObstruction.hh; out.u = targetObstruction.u; out.v = targetObstruction.v; if (moveRequest.m_MinRange > fixed::Zero() || moveRequest.m_MaxRange > fixed::Zero() || targetObstruction.hw > fixed::Zero()) out.type = PathGoal::SQUARE; else { out.type = PathGoal::POINT; return true; } entity_pos_t circleRadius = CFixedVector2D(targetObstruction.hw, targetObstruction.hh).Length(); // TODO: because we cannot move to rounded rectangles, we have to make conservative approximations. // This means we might end up in a situation where cons(max-range) < min range < max range < cons(min-range) // When going outside of the min-range or inside the max-range, the unit will still go through the correct range // but if it moves fast enough, this might not be picked up by PossiblyAtDestination(). // Fixing this involves moving to rounded rectangles, or checking more often in PerformMove(). // In the meantime, one should avoid that 'Speed over a turn' > MaxRange - MinRange, in case where // min-range is not 0 and max-range is not infinity. if (distance < moveRequest.m_MinRange) { if (ShouldTreatTargetAsCircle(moveRequest.m_MinRange, circleRadius)) { // We are safely away from the obstruction itself if we are away from the circumscribing circle out.type = PathGoal::INVERTED_CIRCLE; out.hw = circleRadius + moveRequest.m_MinRange; } else { // Distance checks are nearest edge to nearest edge, so we need to account for our clearance // and we must make sure diagonals also fit so multiply by slightly more than sqrt(2) entity_pos_t goalDistance = moveRequest.m_MinRange + m_Clearance * 3 / 2; out.type = PathGoal::INVERTED_SQUARE; out.hw = targetObstruction.hw + goalDistance; out.hh = targetObstruction.hh + goalDistance; } } else if (moveRequest.m_MaxRange >= fixed::Zero() && distance > moveRequest.m_MaxRange) { if (ShouldTreatTargetAsCircle(moveRequest.m_MaxRange, circleRadius)) { entity_pos_t goalDistance = moveRequest.m_MaxRange; // We must go in-range of the inscribed circle, not the circumscribing circle. circleRadius = std::min(targetObstruction.hw, targetObstruction.hh); out.type = PathGoal::CIRCLE; out.hw = circleRadius + goalDistance; } else { // The target is large relative to our range, so treat it as a square and // get close enough that the diagonals come within range entity_pos_t goalDistance = moveRequest.m_MaxRange * 2 / 3; // multiply by slightly less than 1/sqrt(2) out.type = PathGoal::SQUARE; entity_pos_t delta = std::max(goalDistance, m_Clearance + entity_pos_t::FromInt(TERRAIN_TILE_SIZE)/16); // ensure it's far enough to not intersect the building itself out.hw = targetObstruction.hw + delta; out.hh = targetObstruction.hh + delta; } } // Do nothing in particular in case we are already in range. return true; } void CCmpUnitMotion::ComputePathToGoal(const CFixedVector2D& from, const PathGoal& goal) { #if DISABLE_PATHFINDER { CmpPtr cmpPathfinder (GetSimContext(), SYSTEM_ENTITY); CFixedVector2D goalPos = m_FinalGoal.NearestPointOnGoal(from); m_LongPath.m_Waypoints.clear(); m_ShortPath.m_Waypoints.clear(); m_ShortPath.m_Waypoints.emplace_back(Waypoint{ goalPos.X, goalPos.Y }); return; } #endif // If the target is close and we can reach it in a straight line, // then we'll just go along the straight line instead of computing a path. if (m_FailedPathComputations != MAX_FAILED_PATH_COMPUTATIONS_BEFORE_LONG_PATH && TryGoingStraightToTarget(from)) return; // Otherwise we need to compute a path. // If it's close then just do a short path, not a long path // TODO: If it's close on the opposite side of a river then we really // need a long path, so we shouldn't simply check linear distance // the check is arbitrary but should be a reasonably small distance. // To avoid getting stuck because the short-range pathfinder is bounded, occasionally compute a long path instead. if (m_FailedPathComputations != MAX_FAILED_PATH_COMPUTATIONS_BEFORE_LONG_PATH && InShortPathRange(goal, from)) { m_LongPath.m_Waypoints.clear(); RequestShortPath(from, goal, true); } else { if (m_FailedPathComputations == MAX_FAILED_PATH_COMPUTATIONS_BEFORE_LONG_PATH) m_FailedPathComputations++; // This makes sure we don't end up stuck in this special state which can break pathing. m_ShortPath.m_Waypoints.clear(); RequestLongPath(from, goal); } } void CCmpUnitMotion::RequestLongPath(const CFixedVector2D& from, const PathGoal& goal) { CmpPtr cmpPathfinder(GetSystemEntity()); if (!cmpPathfinder) return; // this is by how much our waypoints will be apart at most. // this value here seems sensible enough. PathGoal improvedGoal = goal; improvedGoal.maxdist = SHORT_PATH_MIN_SEARCH_RANGE - entity_pos_t::FromInt(1); cmpPathfinder->SetDebugPath(from.X, from.Y, improvedGoal, m_PassClass); m_ExpectedPathTicket.m_Type = Ticket::LONG_PATH; m_ExpectedPathTicket.m_Ticket = cmpPathfinder->ComputePathAsync(from.X, from.Y, improvedGoal, m_PassClass, GetEntityId()); } void CCmpUnitMotion::RequestShortPath(const CFixedVector2D &from, const PathGoal& goal, bool avoidMovingUnits) { CmpPtr cmpPathfinder(GetSystemEntity()); if (!cmpPathfinder) return; fixed searchRange = SHORT_PATH_MIN_SEARCH_RANGE + SHORT_PATH_SEARCH_RANGE_INCREMENT * m_FailedPathComputations; if (searchRange > SHORT_PATH_MAX_SEARCH_RANGE) searchRange = SHORT_PATH_MAX_SEARCH_RANGE; m_ExpectedPathTicket.m_Type = Ticket::SHORT_PATH; m_ExpectedPathTicket.m_Ticket = cmpPathfinder->ComputeShortPathAsync(from.X, from.Y, m_Clearance, searchRange, goal, m_PassClass, avoidMovingUnits, GetGroup(), GetEntityId()); } bool CCmpUnitMotion::MoveTo(MoveRequest request) { PROFILE("MoveTo"); CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return false; PathGoal goal; if (!ComputeGoal(goal, request)) return false; m_MoveRequest = request; m_FailedPathComputations = 0; - m_FollowKnownImperfectPath = false; + m_FollowKnownImperfectPathCountdown = 0; ComputePathToGoal(cmpPosition->GetPosition2D(), goal); return true; } +bool CCmpUnitMotion::IsTargetRangeReachable(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) +{ + CmpPtr cmpPosition(GetEntityHandle()); + if (!cmpPosition || !cmpPosition->IsInWorld()) + return false; + + MoveRequest request(target, minRange, maxRange); + PathGoal goal; + if (!ComputeGoal(goal, request)) + return false; + + CmpPtr cmpPathfinder(GetSimContext(), SYSTEM_ENTITY); + CFixedVector2D pos = cmpPosition->GetPosition2D(); + return cmpPathfinder->IsGoalReachable(pos.X, pos.Y, goal, m_PassClass); +} + + void CCmpUnitMotion::RenderPath(const WaypointPath& path, std::vector& lines, CColor color) { bool floating = false; CmpPtr cmpPosition(GetEntityHandle()); if (cmpPosition) floating = cmpPosition->CanFloat(); lines.clear(); std::vector waypointCoords; for (size_t i = 0; i < path.m_Waypoints.size(); ++i) { float x = path.m_Waypoints[i].x.ToFloat(); float z = path.m_Waypoints[i].z.ToFloat(); waypointCoords.push_back(x); waypointCoords.push_back(z); lines.push_back(SOverlayLine()); lines.back().m_Color = color; SimRender::ConstructSquareOnGround(GetSimContext(), x, z, 1.0f, 1.0f, 0.0f, lines.back(), floating); } float x = cmpPosition->GetPosition2D().X.ToFloat(); float z = cmpPosition->GetPosition2D().Y.ToFloat(); waypointCoords.push_back(x); waypointCoords.push_back(z); lines.push_back(SOverlayLine()); lines.back().m_Color = color; SimRender::ConstructLineOnGround(GetSimContext(), waypointCoords, lines.back(), floating); } void CCmpUnitMotion::RenderSubmit(SceneCollector& collector) { if (!m_DebugOverlayEnabled) return; RenderPath(m_LongPath, m_DebugOverlayLongPathLines, OVERLAY_COLOR_LONG_PATH); RenderPath(m_ShortPath, m_DebugOverlayShortPathLines, OVERLAY_COLOR_SHORT_PATH); for (size_t i = 0; i < m_DebugOverlayLongPathLines.size(); ++i) collector.Submit(&m_DebugOverlayLongPathLines[i]); for (size_t i = 0; i < m_DebugOverlayShortPathLines.size(); ++i) collector.Submit(&m_DebugOverlayShortPathLines[i]); } Index: ps/trunk/source/simulation2/components/ICmpPathfinder.h =================================================================== --- ps/trunk/source/simulation2/components/ICmpPathfinder.h (revision 23924) +++ ps/trunk/source/simulation2/components/ICmpPathfinder.h (revision 23925) @@ -1,208 +1,214 @@ /* Copyright (C) 2019 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_ICMPPATHFINDER #define INCLUDED_ICMPPATHFINDER #include "simulation2/system/Interface.h" #include "simulation2/components/ICmpObstruction.h" #include "simulation2/helpers/PathGoal.h" #include "simulation2/helpers/Pathfinding.h" #include "maths/FixedVector2D.h" #include #include class IObstructionTestFilter; template class Grid; // Returned by asynchronous workers, used to send messages in the main thread. struct WaypointPath; struct PathResult { PathResult() = default; PathResult(u32 t, entity_id_t n, WaypointPath p) : ticket(t), notify(n), path(p) {}; u32 ticket; entity_id_t notify; WaypointPath path; }; /** * Pathfinder algorithms. * * There are two different modes: a tile-based pathfinder that works over long distances and * accounts for terrain costs but ignore units, and a 'short' vertex-based pathfinder that * provides precise paths and avoids other units. * * Both use the same concept of a PathGoal: either a point, circle or square. * (If the starting point is inside the goal shape then the path will move outwards * to reach the shape's outline.) * * The output is a list of waypoints. */ class ICmpPathfinder : public IComponent { public: /** * Get the list of all known passability classes. */ virtual void GetPassabilityClasses(std::map& passClasses) const = 0; /** * Get the list of passability classes, separating pathfinding classes and others. */ virtual void GetPassabilityClasses( std::map& nonPathfindingPassClasses, std::map& pathfindingPassClasses) const = 0; /** * Get the tag for a given passability class name. * Logs an error and returns something acceptable if the name is unrecognised. */ virtual pass_class_t GetPassabilityClass(const std::string& name) const = 0; virtual entity_pos_t GetClearance(pass_class_t passClass) const = 0; /** * Get the larger clearance in all passability classes. */ virtual entity_pos_t GetMaximumClearance() const = 0; virtual const Grid& GetPassabilityGrid() = 0; /** * Get the accumulated dirtiness information since the last time the AI accessed and flushed it. */ virtual const GridUpdateInformation& GetAIPathfinderDirtinessInformation() const = 0; virtual void FlushAIPathfinderDirtinessInformation() = 0; /** * Get a grid representing the distance to the shore of the terrain tile. */ virtual Grid ComputeShoreGrid(bool expandOnWater = false) = 0; /** * Asynchronous version of ComputePath. * Request a long path computation, asynchronously. * The result will be sent as CMessagePathResult to 'notify'. * Returns a unique non-zero number, which will match the 'ticket' in the result, * so callers can recognise each individual request they make. */ virtual u32 ComputePathAsync(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass, entity_id_t notify) = 0; /* * Request a long-path computation immediately */ virtual void ComputePathImmediate(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass, WaypointPath& ret) const = 0; /** * Request a short path computation, asynchronously. * The result will be sent as CMessagePathResult to 'notify'. * Returns a unique non-zero number, which will match the 'ticket' in the result, * so callers can recognise each individual request they make. */ virtual u32 ComputeShortPathAsync(entity_pos_t x0, entity_pos_t z0, entity_pos_t clearance, entity_pos_t range, const PathGoal& goal, pass_class_t passClass, bool avoidMovingUnits, entity_id_t controller, entity_id_t notify) = 0; /* * Request a short-path computation immediately. */ virtual WaypointPath ComputeShortPathImmediate(const ShortPathRequest& request) const = 0; /** * If the debug overlay is enabled, render the path that will computed by ComputePath. */ virtual void SetDebugPath(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass) = 0; /** + * @return true if the goal is reachable from (x0, z0) for the given passClass, false otherwise. + * Warning: this is synchronous, somewhat expensive and not should not be called too liberally. + */ + virtual bool IsGoalReachable(entity_pos_t x0, entity_pos_t z0, const PathGoal& goal, pass_class_t passClass) = 0; + + /** * Check whether the given movement line is valid and doesn't hit any obstructions * or impassable terrain. * Returns true if the movement is okay. */ virtual bool CheckMovement(const IObstructionTestFilter& filter, entity_pos_t x0, entity_pos_t z0, entity_pos_t x1, entity_pos_t z1, entity_pos_t r, pass_class_t passClass) const = 0; /** * Check whether a unit placed here is valid and doesn't hit any obstructions * or impassable terrain. * When onlyCenterPoint = true, only check the center tile of the unit * @return ICmpObstruction::FOUNDATION_CHECK_SUCCESS if the placement is okay, else * a value describing the type of failure. */ virtual ICmpObstruction::EFoundationCheck CheckUnitPlacement(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t r, pass_class_t passClass, bool onlyCenterPoint = false) const = 0; /** * Check whether a building placed here is valid and doesn't hit any obstructions * or impassable terrain. * @return ICmpObstruction::FOUNDATION_CHECK_SUCCESS if the placement is okay, else * a value describing the type of failure. */ virtual ICmpObstruction::EFoundationCheck CheckBuildingPlacement(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t a, entity_pos_t w, entity_pos_t h, entity_id_t id, pass_class_t passClass) const = 0; /** * Check whether a building placed here is valid and doesn't hit any obstructions * or impassable terrain. * when onlyCenterPoint = true, only check the center tile of the building * @return ICmpObstruction::FOUNDATION_CHECK_SUCCESS if the placement is okay, else * a value describing the type of failure. */ virtual ICmpObstruction::EFoundationCheck CheckBuildingPlacement(const IObstructionTestFilter& filter, entity_pos_t x, entity_pos_t z, entity_pos_t a, entity_pos_t w, entity_pos_t h, entity_id_t id, pass_class_t passClass, bool onlyCenterPoint) const = 0; /** * Toggle the storage and rendering of debug info. */ virtual void SetDebugOverlay(bool enabled) = 0; /** * Toggle the storage and rendering of debug info for the hierarchical pathfinder. */ virtual void SetHierDebugOverlay(bool enabled) = 0; /** * Finish computing asynchronous path requests and send the CMessagePathResult messages. */ virtual void FetchAsyncResultsAndSendMessages() = 0; /** * Tell asynchronous pathfinder threads that they can begin computing paths. */ virtual void StartProcessingMoves(bool useMax) = 0; /** * Regenerates the grid based on the current obstruction list, if necessary */ virtual void UpdateGrid() = 0; /** * Returns some stats about the last ComputePath. */ virtual void GetDebugData(u32& steps, double& time, Grid& grid) const = 0; /** * Sets up the pathfinder passability overlay in Atlas. */ virtual void SetAtlasOverlay(bool enable, pass_class_t passClass = 0) = 0; DECLARE_INTERFACE_TYPE(Pathfinder) }; #endif // INCLUDED_ICMPPATHFINDER Index: ps/trunk/source/simulation2/components/ICmpUnitMotion.cpp =================================================================== --- ps/trunk/source/simulation2/components/ICmpUnitMotion.cpp (revision 23924) +++ ps/trunk/source/simulation2/components/ICmpUnitMotion.cpp (revision 23925) @@ -1,141 +1,147 @@ /* Copyright (C) 2020 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 "ICmpUnitMotion.h" #include "simulation2/system/InterfaceScripted.h" #include "simulation2/scripting/ScriptComponent.h" BEGIN_INTERFACE_WRAPPER(UnitMotion) DEFINE_INTERFACE_METHOD_4("MoveToPointRange", bool, ICmpUnitMotion, MoveToPointRange, entity_pos_t, entity_pos_t, entity_pos_t, entity_pos_t) DEFINE_INTERFACE_METHOD_3("MoveToTargetRange", bool, ICmpUnitMotion, MoveToTargetRange, entity_id_t, entity_pos_t, entity_pos_t) DEFINE_INTERFACE_METHOD_3("MoveToFormationOffset", void, ICmpUnitMotion, MoveToFormationOffset, entity_id_t, entity_pos_t, entity_pos_t) +DEFINE_INTERFACE_METHOD_3("IsTargetRangeReachable", bool, ICmpUnitMotion, IsTargetRangeReachable, entity_id_t, entity_pos_t, entity_pos_t) DEFINE_INTERFACE_METHOD_2("FaceTowardsPoint", void, ICmpUnitMotion, FaceTowardsPoint, entity_pos_t, entity_pos_t) DEFINE_INTERFACE_METHOD_0("StopMoving", void, ICmpUnitMotion, StopMoving) DEFINE_INTERFACE_METHOD_CONST_0("GetCurrentSpeed", fixed, ICmpUnitMotion, GetCurrentSpeed) DEFINE_INTERFACE_METHOD_CONST_0("IsMoveRequested", bool, ICmpUnitMotion, IsMoveRequested) DEFINE_INTERFACE_METHOD_CONST_0("GetSpeed", fixed, ICmpUnitMotion, GetSpeed) DEFINE_INTERFACE_METHOD_CONST_0("GetWalkSpeed", fixed, ICmpUnitMotion, GetWalkSpeed) DEFINE_INTERFACE_METHOD_CONST_0("GetRunMultiplier", fixed, ICmpUnitMotion, GetRunMultiplier) DEFINE_INTERFACE_METHOD_1("SetSpeedMultiplier", void, ICmpUnitMotion, SetSpeedMultiplier, fixed) DEFINE_INTERFACE_METHOD_CONST_0("GetPassabilityClassName", std::string, ICmpUnitMotion, GetPassabilityClassName) DEFINE_INTERFACE_METHOD_CONST_0("GetUnitClearance", entity_pos_t, ICmpUnitMotion, GetUnitClearance) DEFINE_INTERFACE_METHOD_1("SetFacePointAfterMove", void, ICmpUnitMotion, SetFacePointAfterMove, bool) DEFINE_INTERFACE_METHOD_CONST_0("GetFacePointAfterMove", bool, ICmpUnitMotion, GetFacePointAfterMove) DEFINE_INTERFACE_METHOD_1("SetDebugOverlay", void, ICmpUnitMotion, SetDebugOverlay, bool) END_INTERFACE_WRAPPER(UnitMotion) class CCmpUnitMotionScripted : public ICmpUnitMotion { public: DEFAULT_SCRIPT_WRAPPER(UnitMotionScripted) virtual bool MoveToPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t minRange, entity_pos_t maxRange) { return m_Script.Call("MoveToPointRange", x, z, minRange, maxRange); } virtual bool MoveToTargetRange(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) { return m_Script.Call("MoveToTargetRange", target, minRange, maxRange); } virtual void MoveToFormationOffset(entity_id_t target, entity_pos_t x, entity_pos_t z) { m_Script.CallVoid("MoveToFormationOffset", target, x, z); } + virtual bool IsTargetRangeReachable(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) + { + return m_Script.Call("IsTargetRangeReachable", target, minRange, maxRange); + } + virtual void FaceTowardsPoint(entity_pos_t x, entity_pos_t z) { m_Script.CallVoid("FaceTowardsPoint", x, z); } virtual void StopMoving() { m_Script.CallVoid("StopMoving"); } virtual fixed GetCurrentSpeed() const { return m_Script.Call("GetCurrentSpeed"); } virtual bool IsMoveRequested() const { return m_Script.Call("IsMoveRequested"); } virtual fixed GetSpeed() const { return m_Script.Call("GetSpeed"); } virtual fixed GetWalkSpeed() const { return m_Script.Call("GetWalkSpeed"); } virtual fixed GetRunMultiplier() const { return m_Script.Call("GetRunMultiplier"); } virtual void SetSpeedMultiplier(fixed multiplier) { m_Script.CallVoid("SetSpeedMultiplier", multiplier); } virtual fixed GetSpeedMultiplier() const { return m_Script.Call("GetSpeedMultiplier"); } virtual void SetFacePointAfterMove(bool facePointAfterMove) { m_Script.CallVoid("SetFacePointAfterMove", facePointAfterMove); } virtual bool GetFacePointAfterMove() const { return m_Script.Call("GetFacePointAfterMove"); } virtual pass_class_t GetPassabilityClass() const { return m_Script.Call("GetPassabilityClass"); } virtual std::string GetPassabilityClassName() const { return m_Script.Call("GetPassabilityClassName"); } virtual entity_pos_t GetUnitClearance() const { return m_Script.Call("GetUnitClearance"); } virtual void SetDebugOverlay(bool enabled) { m_Script.CallVoid("SetDebugOverlay", enabled); } }; REGISTER_COMPONENT_SCRIPT_WRAPPER(UnitMotionScripted) Index: ps/trunk/source/simulation2/components/ICmpUnitMotion.h =================================================================== --- ps/trunk/source/simulation2/components/ICmpUnitMotion.h (revision 23924) +++ ps/trunk/source/simulation2/components/ICmpUnitMotion.h (revision 23925) @@ -1,140 +1,149 @@ /* Copyright (C) 2020 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_ICMPUNITMOTION #define INCLUDED_ICMPUNITMOTION #include "simulation2/system/Interface.h" #include "simulation2/components/ICmpPathfinder.h" // for pass_class_t #include "simulation2/components/ICmpPosition.h" // for entity_pos_t /** * Motion interface for entities with complex movement capabilities. * (Simpler motion is handled by ICmpMotion instead.) * * It should eventually support different movement speeds, moving to areas * instead of points, moving as part of a group, moving as part of a formation, * etc. */ class ICmpUnitMotion : public IComponent { public: /** * Attempt to walk into range of a to a given point, or as close as possible. * The range is measured from the center of the unit. * If cannot move anywhere at all, or if there is some other error, then returns false. * Otherwise, returns true. * If maxRange is negative, then the maximum range is treated as infinity. */ virtual bool MoveToPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t minRange, entity_pos_t maxRange) = 0; /** * Attempt to walk into range of a given target entity, or as close as possible. * The range is measured between approximately the edges of the unit and the target, so that * maxRange=0 is not unreachably close to the target. * If the unit cannot move anywhere at all, or if there is some other error, then returns false. * Otherwise, returns true. * If maxRange is negative, then the maximum range is treated as infinity. */ virtual bool MoveToTargetRange(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) = 0; /** * Join a formation, and move towards a given offset relative to the formation controller entity. * Continues following the formation until given a different command. */ virtual void MoveToFormationOffset(entity_id_t target, entity_pos_t x, entity_pos_t z) = 0; /** + * Check if the target is reachable. + * Don't take this as absolute gospel since there are things that the pathfinder may not detect, such as + * entity obstructions in the way, but in general it should return satisfactory results. + * The interface is similar to MoveToTargetRange but the move is not attempted. + * @return true if the target is assumed reachable, false otherwise. + */ + virtual bool IsTargetRangeReachable(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) = 0; + + /** * Turn to look towards the given point. */ virtual void FaceTowardsPoint(entity_pos_t x, entity_pos_t z) = 0; /** * Stop moving immediately. */ virtual void StopMoving() = 0; /** * Get the distance travelled over the last turn. */ virtual fixed GetCurrentSpeed() const = 0; /** * @returns true if the unit has a destination. */ virtual bool IsMoveRequested() const = 0; /** * Get the unit template walk speed after modifications. */ virtual fixed GetWalkSpeed() const = 0; /** * Get the unit template running (i.e. max) speed after modifications. */ virtual fixed GetRunMultiplier() const = 0; /** * Returns the ratio of GetSpeed() / GetWalkSpeed(). */ virtual fixed GetSpeedMultiplier() const = 0; /** * Set the current movement speed. * @param speed A multiplier of GetWalkSpeed(). */ virtual void SetSpeedMultiplier(fixed multiplier) = 0; /** * Get the speed at which the unit intends to move. * (regardless of whether the unit is moving or not right now). */ virtual fixed GetSpeed() const = 0; /** * Set whether the unit will turn to face the target point after finishing moving. */ virtual void SetFacePointAfterMove(bool facePointAfterMove) = 0; virtual bool GetFacePointAfterMove() const = 0; /** * Get the unit's passability class. */ virtual pass_class_t GetPassabilityClass() const = 0; /** * Get the passability class name (as defined in pathfinder.xml) */ virtual std::string GetPassabilityClassName() const = 0; /** * Get the unit clearance (used by the Obstruction component) */ virtual entity_pos_t GetUnitClearance() const = 0; /** * Toggle the rendering of debug info. */ virtual void SetDebugOverlay(bool enabled) = 0; DECLARE_INTERFACE_TYPE(UnitMotion) }; #endif // INCLUDED_ICMPUNITMOTION Index: ps/trunk/source/simulation2/helpers/HierarchicalPathfinder.cpp =================================================================== --- ps/trunk/source/simulation2/helpers/HierarchicalPathfinder.cpp (revision 23924) +++ ps/trunk/source/simulation2/helpers/HierarchicalPathfinder.cpp (revision 23925) @@ -1,831 +1,850 @@ -/* Copyright (C) 2019 Wildfire Games. +/* Copyright (C) 2020 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 "HierarchicalPathfinder.h" #include "graphics/Overlay.h" #include "ps/Profile.h" #include "renderer/Scene.h" #include "simulation2/helpers/Grid.h" // Find the root ID of a region, used by InitRegions inline u16 RootID(u16 x, const std::vector& v) { while (v[x] < x) x = v[x]; return x; } void HierarchicalPathfinder::Chunk::InitRegions(int ci, int cj, Grid* grid, pass_class_t passClass) { ENSURE(ci < 256 && cj < 256); // avoid overflows m_ChunkI = ci; m_ChunkJ = cj; memset(m_Regions, 0, sizeof(m_Regions)); int i0 = ci * CHUNK_SIZE; int j0 = cj * CHUNK_SIZE; int i1 = std::min(i0 + CHUNK_SIZE, (int)grid->m_W); int j1 = std::min(j0 + CHUNK_SIZE, (int)grid->m_H); // Efficiently flood-fill the m_Regions grid int regionID = 0; std::vector connect; u16* pCurrentID = NULL; u16 LeftID = 0; u16 DownID = 0; bool Checked = false; // prevent some unneccessary RootID calls connect.reserve(32); // TODO: What's a sensible number? connect.push_back(0); // connect[0] = 0 // Start by filling the grid with 0 for blocked, // and regionID for unblocked for (int j = j0; j < j1; ++j) { for (int i = i0; i < i1; ++i) { pCurrentID = &m_Regions[j-j0][i-i0]; if (!IS_PASSABLE(grid->get(i, j), passClass)) { *pCurrentID = 0; continue; } if (j > j0) DownID = m_Regions[j-1-j0][i-i0]; if (i == i0) LeftID = 0; else LeftID = m_Regions[j-j0][i-1-i0]; if (LeftID > 0) { *pCurrentID = LeftID; if (*pCurrentID != DownID && DownID > 0 && !Checked) { u16 id0 = RootID(DownID, connect); u16 id1 = RootID(LeftID, connect); Checked = true; // this avoids repeatedly connecting the same IDs if (id0 < id1) connect[id1] = id0; else if (id0 > id1) connect[id0] = id1; } else if (DownID == 0) Checked = false; } else if (DownID > 0) { *pCurrentID = DownID; Checked = false; } else { // New ID *pCurrentID = ++regionID; connect.push_back(regionID); Checked = false; } } } // Mark connected regions as being the same ID (i.e. the lowest) m_RegionsID.clear(); for (u16 i = 1; i < regionID+1; ++i) { if (connect[i] != i) connect[i] = RootID(i, connect); else m_RegionsID.push_back(connect[i]); } // Replace IDs by the root ID for (int j = 0; j < CHUNK_SIZE; ++j) for (int i = 0; i < CHUNK_SIZE; ++i) m_Regions[j][i] = connect[m_Regions[j][i]]; } /** * Returns a RegionID for the given global navcell coords * (which must be inside this chunk); */ HierarchicalPathfinder::RegionID HierarchicalPathfinder::Chunk::Get(int i, int j) const { ENSURE(i < CHUNK_SIZE && j < CHUNK_SIZE); return RegionID(m_ChunkI, m_ChunkJ, m_Regions[j][i]); } /** * Return the global navcell coords that correspond roughly to the * center of the given region in this chunk. * (This is not guaranteed to be actually inside the region.) */ void HierarchicalPathfinder::Chunk::RegionCenter(u16 r, int& i_out, int& j_out) const { // Find the mean of i,j coords of navcells in this region: u32 si = 0, sj = 0; // sum of i,j coords u32 n = 0; // number of navcells in region cassert(CHUNK_SIZE < 256); // conservative limit to ensure si and sj don't overflow for (int j = 0; j < CHUNK_SIZE; ++j) { for (int i = 0; i < CHUNK_SIZE; ++i) { if (m_Regions[j][i] == r) { si += i; sj += j; n += 1; } } } // Avoid divide-by-zero if (n == 0) n = 1; i_out = m_ChunkI * CHUNK_SIZE + si / n; j_out = m_ChunkJ * CHUNK_SIZE + sj / n; } /** * Returns the global navcell coords, and the squared distance to the goal * navcell, of whichever navcell inside the given region is closest to * that goal. */ void HierarchicalPathfinder::Chunk::RegionNavcellNearest(u16 r, int iGoal, int jGoal, int& iBest, int& jBest, u32& dist2Best) const { iBest = 0; jBest = 0; dist2Best = std::numeric_limits::max(); for (int j = 0; j < CHUNK_SIZE; ++j) { for (int i = 0; i < CHUNK_SIZE; ++i) { if (m_Regions[j][i] != r) continue; u32 dist2 = (i + m_ChunkI*CHUNK_SIZE - iGoal)*(i + m_ChunkI*CHUNK_SIZE - iGoal) + (j + m_ChunkJ*CHUNK_SIZE - jGoal)*(j + m_ChunkJ*CHUNK_SIZE - jGoal); if (dist2 < dist2Best) { iBest = i + m_ChunkI*CHUNK_SIZE; jBest = j + m_ChunkJ*CHUNK_SIZE; dist2Best = dist2; } } } } /** * Gives the global navcell coords, and the squared distance to the (i0,j0) * navcell, of whichever navcell inside the given region and inside the given goal * is closest to (i0,j0) * Returns true if the goal is inside the region, false otherwise. */ bool HierarchicalPathfinder::Chunk::RegionNearestNavcellInGoal(u16 r, u16 i0, u16 j0, const PathGoal& goal, u16& iOut, u16& jOut, u32& dist2Best) const { // TODO: this should be optimized further. // Most used cases empirically seem to be SQUARE, INVERTED_CIRCLE and then POINT and CIRCLE somehwat equally iOut = 0; jOut = 0; dist2Best = std::numeric_limits::max(); // Calculate the navcell that contains the center of the goal. int gi = (goal.x >> Pathfinding::NAVCELL_SIZE_LOG2).ToInt_RoundToNegInfinity(); int gj = (goal.z >> Pathfinding::NAVCELL_SIZE_LOG2).ToInt_RoundToNegInfinity(); switch(goal.type) { case PathGoal::POINT: { // i and j can be equal to CHUNK_SIZE on the top and right borders of the map, // specially when mapSize is a multiple of CHUNK_SIZE int i = std::min((int)CHUNK_SIZE - 1, gi - m_ChunkI * CHUNK_SIZE); int j = std::min((int)CHUNK_SIZE - 1, gj - m_ChunkJ * CHUNK_SIZE); if (m_Regions[j][i] == r) { iOut = gi; jOut = gj; dist2Best = (gi - i0)*(gi - i0) + (gj - j0)*(gj - j0); return true; } return false; } case PathGoal::CIRCLE: case PathGoal::SQUARE: { // restrict ourselves to a square surrounding the goal. int radius = (std::max(goal.hw*3/2,goal.hh*3/2) >> Pathfinding::NAVCELL_SIZE_LOG2).ToInt_RoundToInfinity(); int imin = std::max(0, gi-m_ChunkI*CHUNK_SIZE-radius); int imax = std::min((int)CHUNK_SIZE, gi-m_ChunkI*CHUNK_SIZE+radius+1); int jmin = std::max(0, gj-m_ChunkJ*CHUNK_SIZE-radius); int jmax = std::min((int)CHUNK_SIZE, gj-m_ChunkJ*CHUNK_SIZE+radius+1); bool found = false; u32 dist2 = std::numeric_limits::max(); for (u16 j = jmin; j < jmax; ++j) { for (u16 i = imin; i < imax; ++i) { if (m_Regions[j][i] != r) continue; if (found) { dist2 = (i + m_ChunkI*CHUNK_SIZE - i0)*(i + m_ChunkI*CHUNK_SIZE - i0) + (j + m_ChunkJ*CHUNK_SIZE - j0)*(j + m_ChunkJ*CHUNK_SIZE - j0); if (dist2 >= dist2Best) continue; } if (goal.NavcellContainsGoal(m_ChunkI * CHUNK_SIZE + i, m_ChunkJ * CHUNK_SIZE + j)) { if (!found) { found = true; dist2 = (i + m_ChunkI*CHUNK_SIZE - i0)*(i + m_ChunkI*CHUNK_SIZE - i0) + (j + m_ChunkJ*CHUNK_SIZE - j0)*(j + m_ChunkJ*CHUNK_SIZE - j0); } iOut = i + m_ChunkI*CHUNK_SIZE; jOut = j + m_ChunkJ*CHUNK_SIZE; dist2Best = dist2; } } } return found; } case PathGoal::INVERTED_CIRCLE: case PathGoal::INVERTED_SQUARE: { bool found = false; u32 dist2 = std::numeric_limits::max(); // loop over all navcells. for (u16 j = 0; j < CHUNK_SIZE; ++j) { for (u16 i = 0; i < CHUNK_SIZE; ++i) { if (m_Regions[j][i] != r) continue; if (found) { dist2 = (i + m_ChunkI*CHUNK_SIZE - i0)*(i + m_ChunkI*CHUNK_SIZE - i0) + (j + m_ChunkJ*CHUNK_SIZE - j0)*(j + m_ChunkJ*CHUNK_SIZE - j0); if (dist2 >= dist2Best) continue; } if (goal.NavcellContainsGoal(m_ChunkI * CHUNK_SIZE + i, m_ChunkJ * CHUNK_SIZE + j)) { if (!found) { found = true; dist2 = (i + m_ChunkI*CHUNK_SIZE - i0)*(i + m_ChunkI*CHUNK_SIZE - i0) + (j + m_ChunkJ*CHUNK_SIZE - j0)*(j + m_ChunkJ*CHUNK_SIZE - j0); } iOut = i + m_ChunkI*CHUNK_SIZE; jOut = j + m_ChunkJ*CHUNK_SIZE; dist2Best = dist2; } } } return found; } } return false; } HierarchicalPathfinder::HierarchicalPathfinder() : m_DebugOverlay(NULL) { } HierarchicalPathfinder::~HierarchicalPathfinder() { SAFE_DELETE(m_DebugOverlay); } void HierarchicalPathfinder::SetDebugOverlay(bool enabled, const CSimContext* simContext) { if (enabled && !m_DebugOverlay) { m_DebugOverlay = new HierarchicalOverlay(*this); m_DebugOverlayLines.clear(); m_SimContext = simContext; AddDebugEdges(GetPassabilityClass("default")); } else if (!enabled && m_DebugOverlay) { SAFE_DELETE(m_DebugOverlay); m_DebugOverlayLines.clear(); m_SimContext = NULL; } } void HierarchicalPathfinder::RenderSubmit(SceneCollector& collector) { if (!m_DebugOverlay) return; for (size_t i = 0; i < m_DebugOverlayLines.size(); ++i) collector.Submit(&m_DebugOverlayLines[i]); } void HierarchicalPathfinder::Recompute(Grid* grid, const std::map& nonPathfindingPassClassMasks, const std::map& pathfindingPassClassMasks) { PROFILE2("Hierarchical Recompute"); m_PassClassMasks = pathfindingPassClassMasks; std::map allPassClasses = m_PassClassMasks; allPassClasses.insert(nonPathfindingPassClassMasks.begin(), nonPathfindingPassClassMasks.end()); m_W = grid->m_W; m_H = grid->m_H; ENSURE((grid->m_W + CHUNK_SIZE - 1) / CHUNK_SIZE < 256 && (grid->m_H + CHUNK_SIZE - 1) / CHUNK_SIZE < 256); // else the u8 Chunk::m_ChunkI will overflow // Divide grid into chunks with round-to-positive-infinity m_ChunksW = (grid->m_W + CHUNK_SIZE - 1) / CHUNK_SIZE; m_ChunksH = (grid->m_H + CHUNK_SIZE - 1) / CHUNK_SIZE; m_Chunks.clear(); m_Edges.clear(); // Reset global regions. m_NextGlobalRegionID = 1; for (auto& passClassMask : allPassClasses) { pass_class_t passClass = passClassMask.second; // Compute the regions within each chunk m_Chunks[passClass].resize(m_ChunksW*m_ChunksH); for (int cj = 0; cj < m_ChunksH; ++cj) { for (int ci = 0; ci < m_ChunksW; ++ci) { m_Chunks[passClass].at(cj*m_ChunksW + ci).InitRegions(ci, cj, grid, passClass); } } // Construct the search graph over the regions. EdgesMap& edges = m_Edges[passClass]; RecomputeAllEdges(passClass, edges); // Spread global regions. std::map& globalRegion = m_GlobalRegions[passClass]; globalRegion.clear(); for (u8 cj = 0; cj < m_ChunksH; ++cj) for (u8 ci = 0; ci < m_ChunksW; ++ci) for (u16 rid : GetChunk(ci, cj, passClass).m_RegionsID) { RegionID reg{ci,cj,rid}; if (globalRegion.find(reg) == globalRegion.end()) { GlobalRegionID ID = m_NextGlobalRegionID++; globalRegion.insert({ reg, ID }); // Avoid creating an empty link if possible, FindReachableRegions uses [] which calls the default constructor. if (edges.find(reg) != edges.end()) { std::set reachable; FindReachableRegions(reg, reachable, passClass); for (const RegionID& region : reachable) globalRegion.insert({ region, ID }); } } } } if (m_DebugOverlay) { m_DebugOverlayLines.clear(); AddDebugEdges(GetPassabilityClass("default")); } } void HierarchicalPathfinder::Update(Grid* grid, const Grid& dirtinessGrid) { PROFILE3("Hierarchical Update"); ASSERT(m_NextGlobalRegionID < std::numeric_limits::max()); std::map > needNewGlobalRegionMap; // Algorithm for the partial update: // 1. Loop over chunks. // 2. For any dirty chunk: // - remove all regions from the global region map // - remove all edges, by removing the neighbor connection with them and then deleting us // - recreate regions inside the chunk // - reconnect the regions. We may do too much work if we reconnect with a dirty chunk, but that's fine. // 3. Recreate global regions. // This means that if any chunk changes, we may need to flood (at most once) the whole map. // That's quite annoying, but I can't think of an easy way around it. // If we could be sure that a region's topology hasn't changed, we could skip removing its global region // but that's non trivial as we have no easy way to determine said topology (regions could "switch" IDs on update for now). for (u8 cj = 0; cj < m_ChunksH; ++cj) { int j0 = cj * CHUNK_SIZE; int j1 = std::min(j0 + CHUNK_SIZE, (int)dirtinessGrid.m_H); for (u8 ci = 0; ci < m_ChunksW; ++ci) { // Skip chunks where no navcells are dirty. int i0 = ci * CHUNK_SIZE; int i1 = std::min(i0 + CHUNK_SIZE, (int)dirtinessGrid.m_W); if (!dirtinessGrid.any_set_in_square(i0, j0, i1, j1)) continue; for (const std::pair& passClassMask : m_PassClassMasks) { pass_class_t passClass = passClassMask.second; Chunk& a = m_Chunks[passClass].at(ci + cj*m_ChunksW); // Clean up edges and global region ID EdgesMap& edgeMap = m_Edges[passClass]; for (u16 i : a.m_RegionsID) { RegionID reg{ci, cj, i}; m_GlobalRegions[passClass].erase(reg); for (const RegionID& neighbor : edgeMap[reg]) { edgeMap[neighbor].erase(reg); if (edgeMap[neighbor].empty()) edgeMap.erase(neighbor); } edgeMap.erase(reg); } // Recompute regions inside this chunk. a.InitRegions(ci, cj, grid, passClass); for (u16 i : a.m_RegionsID) needNewGlobalRegionMap[passClass].push_back(RegionID{ci, cj, i}); UpdateEdges(ci, cj, passClass, edgeMap); } } } UpdateGlobalRegions(needNewGlobalRegionMap); if (m_DebugOverlay) { m_DebugOverlayLines.clear(); AddDebugEdges(GetPassabilityClass("default")); } } void HierarchicalPathfinder::ComputeNeighbors(EdgesMap& edges, Chunk& a, Chunk& b, bool transpose, bool opposite) const { // For each edge between chunks, we loop over every adjacent pair of // navcells in the two chunks. If they are both in valid regions // (i.e. are passable navcells) then add a graph edge between those regions. // (We don't need to test for duplicates since EdgesMap already uses a // std::set which will drop duplicate entries.) // But as set.insert can be quite slow on large collection, and that we usually // try to insert the same values, we cache the previous one for a fast test. RegionID raPrev(0,0,0); RegionID rbPrev(0,0,0); for (int k = 0; k < CHUNK_SIZE; ++k) { u8 aSide = opposite ? CHUNK_SIZE - 1 : 0; u8 bSide = CHUNK_SIZE - 1 - aSide; RegionID ra = transpose ? a.Get(k, aSide) : a.Get(aSide, k); RegionID rb = transpose ? b.Get(k, bSide) : b.Get(bSide, k); if (ra.r && rb.r) { if (ra == raPrev && rb == rbPrev) continue; edges[ra].insert(rb); edges[rb].insert(ra); raPrev = ra; rbPrev = rb; } } } /** * Connect a chunk's regions to their neighbors. Not optimised for global recomputing. */ void HierarchicalPathfinder::UpdateEdges(u8 ci, u8 cj, pass_class_t passClass, EdgesMap& edges) { std::vector& chunks = m_Chunks[passClass]; Chunk& a = chunks.at(cj*m_ChunksW + ci); if (ci > 0) ComputeNeighbors(edges, a, chunks.at(cj*m_ChunksW + (ci-1)), false, false); if (ci < m_ChunksW-1) ComputeNeighbors(edges, a, chunks.at(cj*m_ChunksW + (ci+1)), false, true); if (cj > 0) ComputeNeighbors(edges, a, chunks.at((cj-1)*m_ChunksW + ci), true, false); if (cj < m_ChunksH - 1) ComputeNeighbors(edges, a, chunks.at((cj+1)*m_ChunksW + ci), true, true); } /** * Find edges between regions in all chunks, in an optimised manner (only look at top/left) */ void HierarchicalPathfinder::RecomputeAllEdges(pass_class_t passClass, EdgesMap& edges) { std::vector& chunks = m_Chunks[passClass]; edges.clear(); for (int cj = 0; cj < m_ChunksH; ++cj) { for (int ci = 0; ci < m_ChunksW; ++ci) { Chunk& a = chunks.at(cj*m_ChunksW + ci); if (ci > 0) ComputeNeighbors(edges, a, chunks.at(cj*m_ChunksW + (ci-1)), false, false); if (cj > 0) ComputeNeighbors(edges, a, chunks.at((cj-1)*m_ChunksW + ci), true, false); } } } /** * Debug visualisation of graph edges between regions. */ void HierarchicalPathfinder::AddDebugEdges(pass_class_t passClass) { const EdgesMap& edges = m_Edges[passClass]; const std::vector& chunks = m_Chunks[passClass]; for (auto& edge : edges) { for (const RegionID& region: edge.second) { // Draw a line between the two regions' centers int i0, j0, i1, j1; chunks[edge.first.cj * m_ChunksW + edge.first.ci].RegionCenter(edge.first.r, i0, j0); chunks[region.cj * m_ChunksW + region.ci].RegionCenter(region.r, i1, j1); CFixedVector2D a, b; Pathfinding::NavcellCenter(i0, j0, a.X, a.Y); Pathfinding::NavcellCenter(i1, j1, b.X, b.Y); // Push the endpoints inwards a little to avoid overlaps CFixedVector2D d = b - a; d.Normalize(entity_pos_t::FromInt(1)); a += d; b -= d; std::vector xz; xz.push_back(a.X.ToFloat()); xz.push_back(a.Y.ToFloat()); xz.push_back(b.X.ToFloat()); xz.push_back(b.Y.ToFloat()); m_DebugOverlayLines.emplace_back(); m_DebugOverlayLines.back().m_Color = CColor(1.0, 1.0, 1.0, 1.0); SimRender::ConstructLineOnGround(*m_SimContext, xz, m_DebugOverlayLines.back(), true); } } } void HierarchicalPathfinder::UpdateGlobalRegions(const std::map >& needNewGlobalRegionMap) { // Use FindReachableRegions because we cannot be sure, even if we find a non-dirty chunk nearby, // that we weren't the only bridge connecting that chunk to the rest of the global region. for (const std::pair >& regionsInNeed : needNewGlobalRegionMap) for (const RegionID& reg : regionsInNeed.second) { std::map& globalRegions = m_GlobalRegions[regionsInNeed.first]; // If we have already been given a region, skip us. if (globalRegions.find(reg) != globalRegions.end()) continue; std::set reachable; FindReachableRegions(reg, reachable, regionsInNeed.first); GlobalRegionID ID = m_NextGlobalRegionID++; for (const RegionID& reg : reachable) globalRegions[reg] = ID; } } HierarchicalPathfinder::RegionID HierarchicalPathfinder::Get(u16 i, u16 j, pass_class_t passClass) const { int ci = i / CHUNK_SIZE; int cj = j / CHUNK_SIZE; ENSURE(ci < m_ChunksW && cj < m_ChunksH); return m_Chunks.at(passClass)[cj*m_ChunksW + ci].Get(i % CHUNK_SIZE, j % CHUNK_SIZE); } HierarchicalPathfinder::GlobalRegionID HierarchicalPathfinder::GetGlobalRegion(u16 i, u16 j, pass_class_t passClass) const { return GetGlobalRegion(Get(i, j, passClass), passClass); } HierarchicalPathfinder::GlobalRegionID HierarchicalPathfinder::GetGlobalRegion(RegionID region, pass_class_t passClass) const { return region.r == 0 ? GlobalRegionID(0) : m_GlobalRegions.at(passClass).at(region); } void CreatePointGoalAt(u16 i, u16 j, PathGoal& goal) { PathGoal newGoal; newGoal.type = PathGoal::POINT; Pathfinding::NavcellCenter(i, j, newGoal.x, newGoal.z); goal = newGoal; } bool HierarchicalPathfinder::MakeGoalReachable(u16 i0, u16 j0, PathGoal& goal, pass_class_t passClass) const { PROFILE2("MakeGoalReachable"); u16 iGoal, jGoal; Pathfinding::NearestNavcell(goal.x, goal.z, iGoal, jGoal, m_W, m_H); std::set goalRegions(SortByBestToPoint(i0, j0)); // This returns goal regions ordered by distance from the best navcell in each region. FindGoalRegionsAndBestNavcells(i0, j0, iGoal, jGoal, goal, goalRegions, passClass); // Because of the sorting above, we can stop as soon as the first reachable goal region is found. for (const InterestingRegion& region : goalRegions) if (GetGlobalRegion(region.region, passClass) == GetGlobalRegion(i0, j0, passClass)) { iGoal = region.bestI; jGoal = region.bestJ; // No need to move reachable point goals. if (goal.type != PathGoal::POINT) CreatePointGoalAt(iGoal, jGoal, goal); return true; } // Goal wasn't reachable - get the closest navcell in the nearest reachable region. std::set reachableRegions(SortByCenterToPoint(iGoal, jGoal)); FindReachableRegions(Get(i0, j0, passClass), reachableRegions, passClass); FindNearestNavcellInRegions(reachableRegions, iGoal, jGoal, passClass); CreatePointGoalAt(iGoal, jGoal, goal); return false; } + +bool HierarchicalPathfinder::IsGoalReachable(u16 i0, u16 j0, const PathGoal& goal, pass_class_t passClass) const +{ + PROFILE2("IsGoalReachable"); + + u16 iGoal, jGoal; + Pathfinding::NearestNavcell(goal.x, goal.z, iGoal, jGoal, m_W, m_H); + + std::set goalRegions(SortByBestToPoint(i0, j0)); + // This returns goal regions ordered by distance from the best navcell in each region. + FindGoalRegionsAndBestNavcells(i0, j0, iGoal, jGoal, goal, goalRegions, passClass); + + // Because of the sorting above, we can stop as soon as the first reachable goal region is found. + for (const InterestingRegion& region : goalRegions) + if (GetGlobalRegion(region.region, passClass) == GetGlobalRegion(i0, j0, passClass)) + return true; + return false; +} + void HierarchicalPathfinder::FindNearestPassableNavcell(u16& i, u16& j, pass_class_t passClass) const { std::set regions(SortByCenterToPoint(i, j)); // Construct a set of all regions of all chunks for this pass class for (const Chunk& chunk : m_Chunks.at(passClass)) for (int r : chunk.m_RegionsID) regions.insert(RegionID(chunk.m_ChunkI, chunk.m_ChunkJ, r)); FindNearestNavcellInRegions(regions, i, j, passClass); } void HierarchicalPathfinder::FindNearestNavcellInRegions(const std::set& regions, u16& iGoal, u16& jGoal, pass_class_t passClass) const { u16 bestI = iGoal, bestJ = jGoal; // Somewhat sensible default-values should regions() be passed empty. u32 bestDist = std::numeric_limits::max(); // Because regions are sorted by increasing distance, we can ignore regions that are obviously farther than the current best point. // Since regions are squares, that happens when the center of a region is at least √2 * CHUNK_SIZE farther than the current best point. // Add one to avoid cases where the center navcell is actually slightly off-center (= CHUNK_SIZE is even) u32 maxDistFromBest = (fixed::FromInt(3) / 2 * CHUNK_SIZE).ToInt_RoundToInfinity() + 1; // TODO: update to static_assert with constexpr ENSURE(maxDistFromBest < std::numeric_limits::max()); maxDistFromBest *= maxDistFromBest; for (const RegionID& region : regions) { u32 chunkDist = region.DistanceTo(iGoal, jGoal); // This might overflow, but only if we are already close to the maximal possible distance, so the condition would probably be false anyways. // It's also a bit pessimistic, so we'll still consider a few too many regions. if (bestDist < std::numeric_limits::max() && chunkDist > maxDistFromBest + bestDist) break; // Break, the set is ordered by increased distance so a closer region will not be found. int ri, rj; u32 dist; GetChunk(region.ci, region.cj, passClass).RegionNavcellNearest(region.r, iGoal, jGoal, ri, rj, dist); if (dist < bestDist) { bestI = ri; bestJ = rj; bestDist = dist; } } iGoal = bestI; jGoal = bestJ; } void HierarchicalPathfinder::FindGoalRegionsAndBestNavcells(u16 i0, u16 j0, u16 gi, u16 gj, const PathGoal& goal, std::set& regions, pass_class_t passClass) const { if (goal.type == PathGoal::POINT) { RegionID region = Get(gi, gj, passClass); if (region.r > 0) regions.insert({region, gi, gj}); return; } // For non-point cases, we'll test each region inside the bounds of the goal. // we might occasionally test a few too many for circles but it's not too bad. // Note that this also works in the Inverse-circle / Inverse-square case // Since our ranges are inclusive, we will necessarily test at least the perimeter/outer bound of the goal. // If we find a navcell, great, if not, well then we'll be surrounded by an impassable barrier. // Since in the Inverse-XX case we're supposed to start inside, then we can't ever reach the goal so it's good enough. // It's not worth it to skip the "inner" regions since we'd need ranges above CHUNK_SIZE for that to start mattering // (and even then not always) and that just doesn't happen for Inverse-XX goals int size = (std::max(goal.hh, goal.hw) * 3 / 2).ToInt_RoundToInfinity(); u16 bestI, bestJ; u32 c; // Unused. for (u8 sz = std::max(0,(gj - size) / CHUNK_SIZE); sz <= std::min(m_ChunksH-1, (gj + size + 1) / CHUNK_SIZE); ++sz) for (u8 sx = std::max(0,(gi - size) / CHUNK_SIZE); sx <= std::min(m_ChunksW-1, (gi + size + 1) / CHUNK_SIZE); ++sx) { const Chunk& chunk = GetChunk(sx, sz, passClass); for (u16 i : chunk.m_RegionsID) if (chunk.RegionNearestNavcellInGoal(i, i0, j0, goal, bestI, bestJ, c)) regions.insert({RegionID{sx, sz, i}, bestI, bestJ}); } } void HierarchicalPathfinder::FillRegionOnGrid(const RegionID& region, pass_class_t passClass, u16 value, Grid& grid) const { ENSURE(grid.m_W == m_W && grid.m_H == m_H); int i0 = region.ci * CHUNK_SIZE; int j0 = region.cj * CHUNK_SIZE; const Chunk& c = m_Chunks.at(passClass)[region.cj * m_ChunksW + region.ci]; for (int j = 0; j < CHUNK_SIZE; ++j) for (int i = 0; i < CHUNK_SIZE; ++i) if (c.m_Regions[j][i] == region.r) grid.set(i0 + i, j0 + j, value); } Grid HierarchicalPathfinder::GetConnectivityGrid(pass_class_t passClass) const { Grid connectivityGrid(m_W, m_H); connectivityGrid.reset(); u16 idx = 1; for (size_t i = 0; i < m_W; ++i) { for (size_t j = 0; j < m_H; ++j) { if (connectivityGrid.get(i, j) != 0) continue; RegionID from = Get(i, j, passClass); if (from.r == 0) continue; std::set reachable; FindReachableRegions(from, reachable, passClass); for (const RegionID& region : reachable) FillRegionOnGrid(region, passClass, idx, connectivityGrid); ++idx; } } return connectivityGrid; } Index: ps/trunk/source/simulation2/helpers/HierarchicalPathfinder.h =================================================================== --- ps/trunk/source/simulation2/helpers/HierarchicalPathfinder.h (revision 23924) +++ ps/trunk/source/simulation2/helpers/HierarchicalPathfinder.h (revision 23925) @@ -1,328 +1,334 @@ -/* Copyright (C) 2019 Wildfire Games. +/* Copyright (C) 2020 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_HIERPATHFINDER #define INCLUDED_HIERPATHFINDER #include "Pathfinding.h" #include "renderer/TerrainOverlay.h" #include "Render.h" #include "graphics/SColor.h" /** * Hierarchical pathfinder. * * Deals with connectivity (can point A reach point B?). * * The navcell-grid representation of the map is split into fixed-size chunks. * Within a chunk, each maximal set of adjacently-connected passable navcells * is defined as a region. * Each region is a vertex in the hierarchical pathfinder's graph. * When two regions in adjacent chunks are connected by passable navcells, * the graph contains an edge between the corresponding two vertexes. * By design, there can never be an edge between two regions in the same chunk. * * Those fixed-size chunks are used to efficiently compute "global regions" by effectively flood-filling. * Those can then be used to immediately determine if two reachables points are connected. * * The main use of this class is to convert an arbitrary PathGoal to a reachable navcell. * This happens in MakeGoalReachable. * */ #ifdef TEST class TestCmpPathfinder; class TestHierarchicalPathfinder; #endif class HierarchicalOverlay; class SceneCollector; class HierarchicalPathfinder { #ifdef TEST friend class TestCmpPathfinder; friend class TestHierarchicalPathfinder; #endif public: typedef u32 GlobalRegionID; struct RegionID { u8 ci, cj; // chunk ID u16 r; // unique-per-chunk local region ID RegionID(u8 ci, u8 cj, u16 r) : ci(ci), cj(cj), r(r) { } bool operator<(const RegionID& b) const { // Sort by chunk ID, then by per-chunk region ID if (ci < b.ci) return true; if (b.ci < ci) return false; if (cj < b.cj) return true; if (b.cj < cj) return false; return r < b.r; } bool operator==(const RegionID& b) const { return ((ci == b.ci) && (cj == b.cj) && (r == b.r)); } // Returns the distance from the center to the point (i, j) inline u32 DistanceTo(u16 i, u16 j) const { return (ci * CHUNK_SIZE + CHUNK_SIZE/2 - i) * (ci * CHUNK_SIZE + CHUNK_SIZE/2 - i) + (cj * CHUNK_SIZE + CHUNK_SIZE/2 - j) * (cj * CHUNK_SIZE + CHUNK_SIZE/2 - j); } }; HierarchicalPathfinder(); ~HierarchicalPathfinder(); void SetDebugOverlay(bool enabled, const CSimContext* simContext); // Non-pathfinding grids will never be recomputed on calling HierarchicalPathfinder::Update void Recompute(Grid* passabilityGrid, const std::map& nonPathfindingPassClassMasks, const std::map& pathfindingPassClassMasks); void Update(Grid* grid, const Grid& dirtinessGrid); RegionID Get(u16 i, u16 j, pass_class_t passClass) const; GlobalRegionID GetGlobalRegion(u16 i, u16 j, pass_class_t passClass) const; GlobalRegionID GetGlobalRegion(RegionID region, pass_class_t passClass) const; /** * Updates @p goal so that it's guaranteed to be reachable from the navcell * @p i0, @p j0 (which is assumed to be on a passable navcell). * * If the goal is not reachable, it is replaced with a point goal nearest to * the goal center. * * In the case of a non-point reachable goal, it is replaced with a point goal * at the reachable navcell of the goal which is nearest to the starting navcell. * * @returns true if the goal was reachable, false otherwise. */ bool MakeGoalReachable(u16 i0, u16 j0, PathGoal& goal, pass_class_t passClass) const; /** + * @return true if the goal is reachable from navcell i0, j0. + * (similar to MakeGoalReachable but only checking for reachability). + */ + bool IsGoalReachable(u16 i0, u16 j0, const PathGoal& goal, pass_class_t passClass) const; + + /** * Updates @p i, @p j (which is assumed to be an impassable navcell) * to the nearest passable navcell. */ void FindNearestPassableNavcell(u16& i, u16& j, pass_class_t passClass) const; /** * Generates the connectivity grid associated with the given pass_class */ Grid GetConnectivityGrid(pass_class_t passClass) const; pass_class_t GetPassabilityClass(const std::string& name) const { auto it = m_PassClassMasks.find(name); if (it != m_PassClassMasks.end()) return it->second; LOGERROR("Invalid passability class name '%s'", name.c_str()); return 0; } void RenderSubmit(SceneCollector& collector); private: static const u8 CHUNK_SIZE = 96; // number of navcells per side // TODO: figure out best number. Probably 64 < n < 128 struct Chunk { u8 m_ChunkI, m_ChunkJ; // chunk ID std::vector m_RegionsID; // IDs of local regions, 0 (impassable) excluded u16 m_Regions[CHUNK_SIZE][CHUNK_SIZE]; // local region ID per navcell cassert(CHUNK_SIZE*CHUNK_SIZE/2 < 65536); // otherwise we could overflow m_RegionsID with a checkerboard pattern void InitRegions(int ci, int cj, Grid* grid, pass_class_t passClass); RegionID Get(int i, int j) const; void RegionCenter(u16 r, int& i, int& j) const; void RegionNavcellNearest(u16 r, int iGoal, int jGoal, int& iBest, int& jBest, u32& dist2Best) const; bool RegionNearestNavcellInGoal(u16 r, u16 i0, u16 j0, const PathGoal& goal, u16& iOut, u16& jOut, u32& dist2Best) const; #ifdef TEST bool operator==(const Chunk& b) const { return (m_ChunkI == b.m_ChunkI && m_ChunkJ == b.m_ChunkJ && m_RegionsID.size() == b.m_RegionsID.size() && memcmp(&m_Regions, &b.m_Regions, sizeof(u16) * CHUNK_SIZE * CHUNK_SIZE) == 0); } #endif }; const Chunk& GetChunk(u8 ci, u8 cj, pass_class_t passClass) const { return m_Chunks.at(passClass).at(cj * m_ChunksW + ci); } typedef std::map > EdgesMap; void ComputeNeighbors(EdgesMap& edges, Chunk& a, Chunk& b, bool transpose, bool opposite) const; void RecomputeAllEdges(pass_class_t passClass, EdgesMap& edges); void UpdateEdges(u8 ci, u8 cj, pass_class_t passClass, EdgesMap& edges); void UpdateGlobalRegions(const std::map >& needNewGlobalRegionMap); /** * Returns all reachable regions, optionally ordered in a specific manner. */ template void FindReachableRegions(RegionID from, std::set& reachable, pass_class_t passClass) const { // Flood-fill the region graph, starting at 'from', // collecting all the regions that are reachable via edges reachable.insert(from); const EdgesMap& edgeMap = m_Edges.at(passClass); if (edgeMap.find(from) == edgeMap.end()) return; std::vector open; open.reserve(64); open.push_back(from); while (!open.empty()) { RegionID curr = open.back(); open.pop_back(); for (const RegionID& region : edgeMap.at(curr)) // Add to the reachable set; if this is the first time we added // it then also add it to the open list if (reachable.insert(region).second) open.push_back(region); } } struct SortByCenterToPoint { SortByCenterToPoint(u16 i, u16 j): gi(i), gj(j) {}; bool operator()(const HierarchicalPathfinder::RegionID& a, const HierarchicalPathfinder::RegionID& b) const { if (a.DistanceTo(gi, gj) < b.DistanceTo(gi, gj)) return true; if (a.DistanceTo(gi, gj) > b.DistanceTo(gi, gj)) return false; return a.r < b.r; } u16 gi, gj; }; void FindNearestNavcellInRegions(const std::set& regions, u16& iGoal, u16& jGoal, pass_class_t passClass) const; struct InterestingRegion { RegionID region; u16 bestI; u16 bestJ; }; struct SortByBestToPoint { SortByBestToPoint(u16 i, u16 j): gi(i), gj(j) {}; bool operator()(const InterestingRegion& a, const InterestingRegion& b) const { if ((a.bestI - gi) * (a.bestI - gi) + (a.bestJ - gj) * (a.bestJ - gj) < (b.bestI - gi) * (b.bestI - gi) + (b.bestJ - gj) * (b.bestJ - gj)) return true; if ((a.bestI - gi) * (a.bestI - gi) + (a.bestJ - gj) * (a.bestJ - gj) > (b.bestI - gi) * (b.bestI - gi) + (b.bestJ - gj) * (b.bestJ - gj)) return false; return a.region.r < b.region.r; } u16 gi, gj; }; // Returns the region along with the best cell for optimisation. void FindGoalRegionsAndBestNavcells(u16 i0, u16 j0, u16 gi, u16 gj, const PathGoal& goal, std::set& regions, pass_class_t passClass) const; void FillRegionOnGrid(const RegionID& region, pass_class_t passClass, u16 value, Grid& grid) const; u16 m_W, m_H; u8 m_ChunksW, m_ChunksH; std::map > m_Chunks; std::map m_Edges; std::map > m_GlobalRegions; GlobalRegionID m_NextGlobalRegionID; // Passability classes for which grids will be updated when calling Update std::map m_PassClassMasks; void AddDebugEdges(pass_class_t passClass); HierarchicalOverlay* m_DebugOverlay; const CSimContext* m_SimContext; // Used for drawing the debug lines public: std::vector m_DebugOverlayLines; }; class HierarchicalOverlay : public TerrainTextureOverlay { public: HierarchicalPathfinder& m_PathfinderHier; HierarchicalOverlay(HierarchicalPathfinder& pathfinderHier) : TerrainTextureOverlay(Pathfinding::NAVCELLS_PER_TILE), m_PathfinderHier(pathfinderHier) { } virtual void BuildTextureRGBA(u8* data, size_t w, size_t h) { pass_class_t passClass = m_PathfinderHier.GetPassabilityClass("default"); for (size_t j = 0; j < h; ++j) { for (size_t i = 0; i < w; ++i) { SColor4ub color; HierarchicalPathfinder::RegionID rid = m_PathfinderHier.Get(i, j, passClass); if (rid.r == 0) color = SColor4ub(0, 0, 0, 0); else if (rid.r == 0xFFFF) color = SColor4ub(255, 0, 255, 255); else color = GetColor(rid.r + rid.ci*5 + rid.cj*7, 127); *data++ = color.R; *data++ = color.G; *data++ = color.B; *data++ = color.A; } } } }; #endif // INCLUDED_HIERPATHFINDER