Index: ps/trunk/binaries/data/mods/public/simulation/components/Formation.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/Formation.js (revision 22276) +++ ps/trunk/binaries/data/mods/public/simulation/components/Formation.js (revision 22277) @@ -1,1004 +1,1004 @@ function Formation() {} Formation.prototype.Schema = "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; var g_ColumnDistanceThreshold = 128; // distance at which we'll switch between column/box formations Formation.prototype.Init = function() { this.formationShape = this.template.FormationShape; this.sortingClasses = this.template.SortingClasses.split(/\s+/g); this.sortingOrder = this.template.SortingOrder; this.shiftRows = this.template.ShiftRows == "true"; this.separationMultiplier = { "width": +this.template.UnitSeparationWidthMultiplier, "depth": +this.template.UnitSeparationDepthMultiplier }; this.sloppyness = +this.template.Sloppyness; this.widthDepthRatio = +this.template.WidthDepthRatio; this.minColumns = +(this.template.MinColumns || 0); this.maxColumns = +(this.template.MaxColumns || 0); this.maxRows = +(this.template.MaxRows || 0); this.centerGap = +(this.template.CenterGap || 0); var animations = this.template.Animations; this.animations = {}; for (var animationName in animations) { var differentAnimations = animations[animationName].split(/\s*;\s*/); this.animations[animationName] = []; // loop over the different rectangulars that will map to different animations for (var rectAnimation of differentAnimations) { var rect, replacementAnimationName; [rect, replacementAnimationName] = rectAnimation.split(/\s*:\s*/); var rows, columns; [rows, columns] = rect.split(/\s*,\s*/); var minRow, maxRow, minColumn, maxColumn; [minRow, maxRow] = rows.split(/\s*\.\.\s*/); [minColumn, maxColumn] = columns.split(/\s*\.\.\s*/); this.animations[animationName].push({ "minRow": +minRow, "maxRow": +maxRow, "minColumn": +minColumn, "maxColumn": +maxColumn, "animation": replacementAnimationName }); } } this.members = []; // entity IDs currently belonging to this formation this.memberPositions = {}; this.maxRowsUsed = 0; this.maxColumnsUsed = []; this.inPosition = []; // entities that have reached their final position this.columnar = false; // whether we're travelling in column (vs box) formation this.rearrange = true; // whether we should rearrange all formation members this.formationMembersWithAura = []; // Members with a formation aura this.width = 0; this.depth = 0; this.oldOrientation = {"sin": 0, "cos": 0}; this.twinFormations = []; // distance from which two twin formations will merge into one. this.formationSeparation = 0; Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer) .SetInterval(this.entity, IID_Formation, "ShapeUpdate", 1000, 1000, null); }; /** * Set the value from which two twin formations will become one. */ Formation.prototype.SetFormationSeparation = function(value) { this.formationSeparation = value; }; Formation.prototype.GetSize = function() { return {"width": this.width, "depth": this.depth}; }; Formation.prototype.GetSpeedMultiplier = function() { return +this.template.SpeedMultiplier; }; Formation.prototype.GetMemberCount = function() { return this.members.length; }; Formation.prototype.GetMembers = function() { return this.members; }; Formation.prototype.GetClosestMember = function(ent, filter) { var cmpEntPosition = Engine.QueryInterface(ent, IID_Position); if (!cmpEntPosition || !cmpEntPosition.IsInWorld()) return INVALID_ENTITY; var entPosition = cmpEntPosition.GetPosition2D(); var closestMember = INVALID_ENTITY; var closestDistance = Infinity; for (var member of this.members) { if (filter && !filter(ent)) continue; var cmpPosition = Engine.QueryInterface(member, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) continue; var pos = cmpPosition.GetPosition2D(); var dist = entPosition.distanceToSquared(pos); if (dist < closestDistance) { closestMember = member; closestDistance = dist; } } return closestMember; }; /** * Returns the 'primary' member of this formation (typically the most * important unit type), for e.g. playing a representative sound. * Returns undefined if no members. * TODO: actually implement something like that; currently this just returns * the arbitrary first one. */ Formation.prototype.GetPrimaryMember = function() { return this.members[0]; }; /** * Get the formation animation for a certain member of this formation * @param entity The entity ID to get the animation for * @param defaultAnimation The name of the default wanted animation for the entity * E.g. "walk", "idle" ... * @return The name of the transformed animation as defined in the template * E.g. "walk_testudo_row1" */ Formation.prototype.GetFormationAnimation = function(entity, defaultAnimation) { var animationGroup = this.animations[defaultAnimation]; if (!animationGroup || this.columnar || !this.memberPositions[entity]) return defaultAnimation; var row = this.memberPositions[entity].row; var column = this.memberPositions[entity].column; for (var i = 0; i < animationGroup.length; ++i) { var minRow = animationGroup[i].minRow; if (minRow < 0) minRow += this.maxRowsUsed + 1; if (row < minRow) continue; var maxRow = animationGroup[i].maxRow; if (maxRow < 0) maxRow += this.maxRowsUsed + 1; if (row > maxRow) continue; var minColumn = animationGroup[i].minColumn; if (minColumn < 0) minColumn += this.maxColumnsUsed[row] + 1; if (column < minColumn) continue; var maxColumn = animationGroup[i].maxColumn; if (maxColumn < 0) maxColumn += this.maxColumnsUsed[row] + 1; if (column > maxColumn) continue; return animationGroup[i].animation; } return defaultAnimation; }; /** * Permits formation members to register that they've reached their destination. */ Formation.prototype.SetInPosition = function(ent) { if (this.inPosition.indexOf(ent) != -1) return; // Rotate the entity to the right angle var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); var cmpEntPosition = Engine.QueryInterface(ent, IID_Position); if (cmpEntPosition && cmpEntPosition.IsInWorld() && cmpPosition && cmpPosition.IsInWorld()) cmpEntPosition.TurnTo(cmpPosition.GetRotation().y); this.inPosition.push(ent); }; /** * Called by formation members upon entering non-walking states. */ Formation.prototype.UnsetInPosition = function(ent) { var ind = this.inPosition.indexOf(ent); if (ind != -1) this.inPosition.splice(ind, 1); }; /** * Set whether we should rearrange formation members if * units are removed from the formation. */ Formation.prototype.SetRearrange = function(rearrange) { this.rearrange = rearrange; }; /** * Initialise the members of this formation. * Must only be called once. * All members must implement UnitAI. */ Formation.prototype.SetMembers = function(ents) { this.members = ents; for (var ent of this.members) { var cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); cmpUnitAI.SetFormationController(this.entity); var cmpAuras = Engine.QueryInterface(ent, IID_Auras); if (cmpAuras && cmpAuras.HasFormationAura()) { this.formationMembersWithAura.push(ent); cmpAuras.ApplyFormationBonus(ents); } } this.offsets = undefined; // Locate this formation controller in the middle of its members this.MoveToMembersCenter(); // Compute the speed etc. of the formation this.ComputeMotionParameters(); }; /** * Remove the given list of entities. * The entities must already be members of this formation. */ Formation.prototype.RemoveMembers = function(ents) { this.offsets = undefined; this.members = this.members.filter(function(e) { return ents.indexOf(e) == -1; }); this.inPosition = this.inPosition.filter(function(e) { return ents.indexOf(e) == -1; }); for (var ent of ents) { var cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); cmpUnitAI.UpdateWorkOrders(); cmpUnitAI.SetFormationController(INVALID_ENTITY); } for (var ent of this.formationMembersWithAura) { var cmpAuras = Engine.QueryInterface(ent, IID_Auras); cmpAuras.RemoveFormationBonus(ents); // the unit with the aura is also removed from the formation if (ents.indexOf(ent) !== -1) cmpAuras.RemoveFormationBonus(this.members); } this.formationMembersWithAura = this.formationMembersWithAura.filter(function(e) { return ents.indexOf(e) == -1; }); // If there's nobody left, destroy the formation if (this.members.length == 0) { Engine.DestroyEntity(this.entity); return; } if (!this.rearrange) return; this.ComputeMotionParameters(); // Rearrange the remaining members this.MoveMembersIntoFormation(true, true); }; Formation.prototype.AddMembers = function(ents) { this.offsets = undefined; this.inPosition = []; for (let ent of this.formationMembersWithAura) { let cmpAuras = Engine.QueryInterface(ent, IID_Auras); cmpAuras.ApplyFormationBonus(ents); } this.members = this.members.concat(ents); for (let ent of ents) { let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); cmpUnitAI.SetFormationController(this.entity); let cmpAuras = Engine.QueryInterface(ent, IID_Auras); if (cmpAuras && cmpAuras.HasFormationAura()) { this.formationMembersWithAura.push(ent); cmpAuras.ApplyFormationBonus(this.members); } } this.MoveMembersIntoFormation(true, true); }; /** * Called when the formation stops moving in order to detect * units that have already reached their final positions. */ Formation.prototype.FindInPosition = function() { for (var i = 0; i < this.members.length; ++i) { var cmpUnitMotion = Engine.QueryInterface(this.members[i], IID_UnitMotion); if (!cmpUnitMotion.IsMoving()) { // Verify that members are stopped in FORMATIONMEMBER.WALKING var cmpUnitAI = Engine.QueryInterface(this.members[i], IID_UnitAI); if (cmpUnitAI.IsWalking()) this.SetInPosition(this.members[i]); } } }; /** * Remove all members and destroy the formation. */ Formation.prototype.Disband = function() { for (var ent of this.members) { var cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); cmpUnitAI.SetFormationController(INVALID_ENTITY); } for (var ent of this.formationMembersWithAura) { var cmpAuras = Engine.QueryInterface(ent, IID_Auras); cmpAuras.RemoveFormationBonus(this.members); } this.members = []; this.inPosition = []; this.formationMembersWithAura = []; this.offsets = undefined; Engine.DestroyEntity(this.entity); }; /** * Set all members to form up into the formation shape. * If moveCenter is true, the formation center will be reinitialised * to the center of the units. * If force is true, all individual orders of the formation units are replaced, * otherwise the order to walk into formation is just pushed to the front. */ Formation.prototype.MoveMembersIntoFormation = function(moveCenter, force) { if (!this.members.length) return; var active = []; var positions = []; for (var ent of this.members) { var cmpPosition = Engine.QueryInterface(ent, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) continue; active.push(ent); // query the 2D position as exact hight calculation isn't needed // but bring the position to the right coordinates var pos = cmpPosition.GetPosition2D(); positions.push(pos); } var avgpos = Vector2D.average(positions); // Reposition the formation if we're told to or if we don't already have a position var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); var inWorld = cmpPosition.IsInWorld(); if (moveCenter || !inWorld) { cmpPosition.JumpTo(avgpos.x, avgpos.y); // Don't make the formation controller entity show up in range queries if (!inWorld) { var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); cmpRangeManager.SetEntityFlag(this.entity, "normal", false); } } // Switch between column and box if necessary var cmpUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI); var walkingDistance = cmpUnitAI.ComputeWalkingDistance(); var columnar = walkingDistance > g_ColumnDistanceThreshold; if (columnar != this.columnar) { this.columnar = columnar; this.offsets = undefined; } var newOrientation = this.GetEstimatedOrientation(avgpos); var dSin = Math.abs(newOrientation.sin - this.oldOrientation.sin); var dCos = Math.abs(newOrientation.cos - this.oldOrientation.cos); // If the formation existed, only recalculate positions if the turning agle is somewhat biggish if (!this.offsets || dSin > 1 || dCos > 1) this.offsets = this.ComputeFormationOffsets(active, positions); this.oldOrientation = newOrientation; var xMax = 0; var yMax = 0; var xMin = 0; var yMin = 0; for (var i = 0; i < this.offsets.length; ++i) { var offset = this.offsets[i]; var cmpUnitAI = Engine.QueryInterface(offset.ent, IID_UnitAI); if (!cmpUnitAI) continue; var data = { "target": this.entity, "x": offset.x, "z": offset.y }; cmpUnitAI.AddOrder("FormationWalk", data, !force); xMax = Math.max(xMax, offset.x); yMax = Math.max(yMax, offset.y); xMin = Math.min(xMin, offset.x); yMin = Math.min(yMin, offset.y); } this.width = xMax - xMin; this.depth = yMax - yMin; }; Formation.prototype.MoveToMembersCenter = function() { var positions = []; for (var ent of this.members) { var cmpPosition = Engine.QueryInterface(ent, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) continue; positions.push(cmpPosition.GetPosition2D()); } var avgpos = Vector2D.average(positions); var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); var inWorld = cmpPosition.IsInWorld(); cmpPosition.JumpTo(avgpos.x, avgpos.y); // Don't make the formation controller show up in range queries if (!inWorld) { var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); cmpRangeManager.SetEntityFlag(this.entity, "normal", false); } }; Formation.prototype.GetAvgFootprint = function(active) { var footprints = []; for (var ent of active) { var cmpFootprint = Engine.QueryInterface(ent, IID_Footprint); if (cmpFootprint) footprints.push(cmpFootprint.GetShape()); } if (!footprints.length) return {"width":1, "depth": 1}; var r = {"width": 0, "depth": 0}; for (var shape of footprints) { if (shape.type == "circle") { r.width += shape.radius * 2; r.depth += shape.radius * 2; } else if (shape.type == "square") { r.width += shape.width; r.depth += shape.depth; } } r.width /= footprints.length; r.depth /= footprints.length; return r; }; Formation.prototype.ComputeFormationOffsets = function(active, positions) { var separation = this.GetAvgFootprint(active); separation.width *= this.separationMultiplier.width; separation.depth *= this.separationMultiplier.depth; if (this.columnar) var sortingClasses = ["Cavalry","Infantry"]; else var sortingClasses = this.sortingClasses.slice(); sortingClasses.push("Unknown"); // the entities will be assigned to positions in the formation in // the same order as the types list is ordered var types = {}; for (var i = 0; i < sortingClasses.length; ++i) types[sortingClasses[i]] = []; for (var i in active) { var cmpIdentity = Engine.QueryInterface(active[i], IID_Identity); var classes = cmpIdentity.GetClassesList(); var done = false; for (var c = 0; c < sortingClasses.length; ++c) { if (classes.indexOf(sortingClasses[c]) > -1) { types[sortingClasses[c]].push({"ent": active[i], "pos": positions[i]}); done = true; break; } } if (!done) types["Unknown"].push({"ent": active[i], "pos": positions[i]}); } var count = active.length; var shape = this.formationShape; var shiftRows = this.shiftRows; var centerGap = this.centerGap; var sortingOrder = this.sortingOrder; var offsets = []; // Choose a sensible size/shape for the various formations, depending on number of units var cols; if (this.columnar) { shape = "square"; cols = Math.min(count,3); shiftRows = false; centerGap = 0; sortingOrder = null; } else { var depth = Math.sqrt(count / this.widthDepthRatio); if (this.maxRows && depth > this.maxRows) depth = this.maxRows; cols = Math.ceil(count / Math.ceil(depth) + (this.shiftRows ? 0.5 : 0)); if (cols < this.minColumns) cols = Math.min(count, this.minColumns); if (this.maxColumns && cols > this.maxColumns && this.maxRows != depth) cols = this.maxColumns; } // define special formations here if (this.template.FormationName == "Scatter") { var width = Math.sqrt(count) * (separation.width + separation.depth) * 2.5; for (var i = 0; i < count; ++i) { var obj = new Vector2D(randFloat(0, width), randFloat(0, width)); obj.row = 1; obj.column = i + 1; offsets.push(obj); } } // For non-special formations, calculate the positions based on the number of entities this.maxColumnsUsed = []; this.maxRowsUsed = 0; if (shape != "special") { offsets = []; var r = 0; var left = count; // while there are units left, start a new row in the formation while (left > 0) { // save the position of the row var z = -r * separation.depth; // switch between the left and right side of the center to have a symmetrical distribution var side = 1; // determine the number of entities in this row of the formation if (shape == "square") { var n = cols; if (shiftRows) n -= r%2; } else if (shape == "triangle") { if (shiftRows) var n = r + 1; else var n = r * 2 + 1; } if (!shiftRows && n > left) n = left; for (var c = 0; c < n && left > 0; ++c) { // switch sides for the next entity side *= -1; if (n%2 == 0) var x = side * (Math.floor(c/2) + 0.5) * separation.width; else var x = side * Math.ceil(c/2) * separation.width; if (centerGap) { if (x == 0) // don't use the center position with a center gap continue; x += side * centerGap / 2; } var column = Math.ceil(n/2) + Math.ceil(c/2) * side; var r1 = randFloat(-1, 1) * this.sloppyness; var r2 = randFloat(-1, 1) * this.sloppyness; offsets.push(new Vector2D(x + r1, z + r2)); offsets[offsets.length - 1].row = r+1; offsets[offsets.length - 1].column = column; left--; } ++r; this.maxColumnsUsed[r] = n; } this.maxRowsUsed = r; } // make sure the average offset is zero, as the formation is centered around that // calculating offset distances without a zero average makes no sense, as the formation // will jump to a different position any time var avgoffset = Vector2D.average(offsets); offsets.forEach(function (o) {o.sub(avgoffset);}); // sort the available places in certain ways // the places first in the list will contain the heaviest units as defined by the order // of the types list if (this.sortingOrder == "fillFromTheSides") offsets.sort(function(o1, o2) { return Math.abs(o1.x) < Math.abs(o2.x);}); else if (this.sortingOrder == "fillToTheCenter") offsets.sort(function(o1, o2) { return Math.max(Math.abs(o1.x), Math.abs(o1.y)) < Math.max(Math.abs(o2.x), Math.abs(o2.y)); }); // query the 2D position of the formation var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); var formationPos = cmpPosition.GetPosition2D(); // use realistic place assignment, // every soldier searches the closest available place in the formation var newOffsets = []; var realPositions = this.GetRealOffsetPositions(offsets, formationPos); for (var i = sortingClasses.length; i; --i) { var t = types[sortingClasses[i-1]]; if (!t.length) continue; var usedOffsets = offsets.splice(-t.length); var usedRealPositions = realPositions.splice(-t.length); for (var entPos of t) { var closestOffsetId = this.TakeClosestOffset(entPos, usedRealPositions, usedOffsets); usedRealPositions.splice(closestOffsetId, 1); newOffsets.push(usedOffsets.splice(closestOffsetId, 1)[0]); newOffsets[newOffsets.length - 1].ent = entPos.ent; } } return newOffsets; }; /** * Search the closest position in the realPositions list to the given entity * @param ent, the queried entity * @param realPositions, the world coordinates of the available offsets * @return the index of the closest offset position */ Formation.prototype.TakeClosestOffset = function(entPos, realPositions, offsets) { var pos = entPos.pos; var closestOffsetId = -1; var offsetDistanceSq = Infinity; for (var i = 0; i < realPositions.length; i++) { var distSq = pos.distanceToSquared(realPositions[i]); if (distSq < offsetDistanceSq) { offsetDistanceSq = distSq; closestOffsetId = i; } } this.memberPositions[entPos.ent] = {"row": offsets[closestOffsetId].row, "column":offsets[closestOffsetId].column}; return closestOffsetId; }; /** * Get the world positions for a list of offsets in this formation */ Formation.prototype.GetRealOffsetPositions = function(offsets, pos) { var offsetPositions = []; var {sin, cos} = this.GetEstimatedOrientation(pos); // calculate the world positions for (var o of offsets) offsetPositions.push(new Vector2D(pos.x + o.y * sin + o.x * cos, pos.y + o.y * cos - o.x * sin)); return offsetPositions; }; /** * calculate the estimated rotation of the formation * based on the first unitAI target position when ordered to walk, * based on the current rotation in other cases * Return the sine and cosine of the angle */ Formation.prototype.GetEstimatedOrientation = function(pos) { var cmpUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI); var r = {"sin": 0, "cos": 1}; var unitAIState = cmpUnitAI.GetCurrentState(); if (unitAIState == "FORMATIONCONTROLLER.WALKING" || unitAIState == "FORMATIONCONTROLLER.COMBAT.APPROACHING") { var targetPos = cmpUnitAI.GetTargetPositions(); if (!targetPos.length) return r; var d = targetPos[0].sub(pos).normalize(); if (!d.x && !d.y) return r; r.cos = d.y; r.sin = d.x; } else { var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition) return r; var rot = cmpPosition.GetRotation().y; r.sin = Math.sin(rot); r.cos = Math.cos(rot); } return r; }; /** * Set formation controller's speed based on its current members. */ Formation.prototype.ComputeMotionParameters = function() { var maxRadius = 0; var minSpeed = Infinity; for (var ent of this.members) { var cmpUnitMotion = Engine.QueryInterface(ent, IID_UnitMotion); if (cmpUnitMotion) minSpeed = Math.min(minSpeed, cmpUnitMotion.GetWalkSpeed()); } minSpeed *= this.GetSpeedMultiplier(); var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); - cmpUnitMotion.SetSpeedRatio(minSpeed / cmpUnitMotion.GetWalkSpeed()); + cmpUnitMotion.SetSpeedMultiplier(minSpeed / cmpUnitMotion.GetWalkSpeed()); }; Formation.prototype.ShapeUpdate = function() { // Check the distance to twin formations, and merge if when // the formations could collide for (var i = this.twinFormations.length - 1; i >= 0; --i) { // only do the check on one side if (this.twinFormations[i] <= this.entity) continue; var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); var cmpOtherPosition = Engine.QueryInterface(this.twinFormations[i], IID_Position); var cmpOtherFormation = Engine.QueryInterface(this.twinFormations[i], IID_Formation); if (!cmpPosition || !cmpOtherPosition || !cmpOtherFormation) continue; var thisPosition = cmpPosition.GetPosition2D(); var otherPosition = cmpOtherPosition.GetPosition2D(); var dx = thisPosition.x - otherPosition.x; var dy = thisPosition.y - otherPosition.y; var dist = Math.sqrt(dx * dx + dy * dy); var thisSize = this.GetSize(); var otherSize = cmpOtherFormation.GetSize(); var minDist = Math.max(thisSize.width / 2, thisSize.depth / 2) + Math.max(otherSize.width / 2, otherSize.depth / 2) + this.formationSeparation; if (minDist < dist) continue; // merge the members from the twin formation into this one // twin formations should always have exactly the same orders let otherMembers = cmpOtherFormation.members; cmpOtherFormation.RemoveMembers(otherMembers); this.AddMembers(otherMembers); Engine.DestroyEntity(this.twinFormations[i]); this.twinFormations.splice(i,1); } // Switch between column and box if necessary var cmpUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI); var walkingDistance = cmpUnitAI.ComputeWalkingDistance(); var columnar = walkingDistance > g_ColumnDistanceThreshold; if (columnar != this.columnar) { this.offsets = undefined; this.columnar = columnar; this.MoveMembersIntoFormation(false, true); // (disable moveCenter so we can't get stuck in a loop of switching // shape causing center to change causing shape to switch back) } }; Formation.prototype.OnGlobalOwnershipChanged = function(msg) { // When an entity is captured or destroyed, it should no longer be // controlled by this formation if (this.members.indexOf(msg.entity) != -1) this.RemoveMembers([msg.entity]); }; Formation.prototype.OnGlobalEntityRenamed = function(msg) { if (this.members.indexOf(msg.entity) != -1) { this.offsets = undefined; var cmpNewUnitAI = Engine.QueryInterface(msg.newentity, IID_UnitAI); if (cmpNewUnitAI) { this.members[this.members.indexOf(msg.entity)] = msg.newentity; this.memberPositions[msg.newentity] = this.memberPositions[msg.entity]; } var cmpOldUnitAI = Engine.QueryInterface(msg.entity, IID_UnitAI); cmpOldUnitAI.SetFormationController(INVALID_ENTITY); if (cmpNewUnitAI) cmpNewUnitAI.SetFormationController(this.entity); // Because the renamed entity might have different characteristics, // (e.g. packed vs. unpacked siege), we need to recompute motion parameters this.ComputeMotionParameters(); } }; Formation.prototype.RegisterTwinFormation = function(entity) { var cmpFormation = Engine.QueryInterface(entity, IID_Formation); if (!cmpFormation) return; this.twinFormations.push(entity); cmpFormation.twinFormations.push(this.entity); }; Formation.prototype.DeleteTwinFormations = function() { for (var ent of this.twinFormations) { var cmpFormation = Engine.QueryInterface(ent, IID_Formation); if (cmpFormation) cmpFormation.twinFormations.splice(cmpFormation.twinFormations.indexOf(this.entity), 1); } this.twinFormations = []; }; Formation.prototype.LoadFormation = function(newTemplate) { // get the old formation info var members = this.members.slice(); var cmpThisUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI); var orders = cmpThisUnitAI.GetOrders().slice(); this.Disband(); var newFormation = Engine.AddEntity(newTemplate); // Apply the info from the old formation to the new one let cmpNewOwnership = Engine.QueryInterface(newFormation, IID_Ownership); let cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (cmpOwnership && cmpNewOwnership) cmpNewOwnership.SetOwner(cmpOwnership.GetOwner()); var cmpNewPosition = Engine.QueryInterface(newFormation, IID_Position); var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (cmpPosition && cmpPosition.IsInWorld() && cmpNewPosition) cmpNewPosition.TurnTo(cmpPosition.GetRotation().y); var cmpFormation = Engine.QueryInterface(newFormation, IID_Formation); var cmpNewUnitAI = Engine.QueryInterface(newFormation, IID_UnitAI); cmpFormation.SetMembers(members); if (orders.length) cmpNewUnitAI.AddOrders(orders); else cmpNewUnitAI.MoveIntoFormation(); Engine.PostMessage(this.entity, MT_EntityRenamed, { "entity": this.entity, "newentity": newFormation }); }; Engine.RegisterComponentType(IID_Formation, "Formation", Formation); Index: ps/trunk/binaries/data/mods/public/simulation/components/GuiInterface.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/GuiInterface.js (revision 22276) +++ ps/trunk/binaries/data/mods/public/simulation/components/GuiInterface.js (revision 22277) @@ -1,1975 +1,1975 @@ function GuiInterface() {} GuiInterface.prototype.Schema = ""; GuiInterface.prototype.Serialize = function() { // This component isn't network-synchronised for the biggest part // So most of the attributes shouldn't be serialized // Return an object with a small selection of deterministic data return { "timeNotifications": this.timeNotifications, "timeNotificationID": this.timeNotificationID }; }; GuiInterface.prototype.Deserialize = function(data) { this.Init(); this.timeNotifications = data.timeNotifications; this.timeNotificationID = data.timeNotificationID; }; GuiInterface.prototype.Init = function() { this.placementEntity = undefined; // = undefined or [templateName, entityID] this.placementWallEntities = undefined; this.placementWallLastAngle = 0; this.notifications = []; this.renamedEntities = []; this.miragedEntities = []; this.timeNotificationID = 1; this.timeNotifications = []; this.entsRallyPointsDisplayed = []; this.entsWithAuraAndStatusBars = new Set(); this.enabledVisualRangeOverlayTypes = {}; this.templateModified = {}; }; /* * All of the functions defined below are called via Engine.GuiInterfaceCall(name, arg) * from GUI scripts, and executed here with arguments (player, arg). * * CAUTION: The input to the functions in this module is not network-synchronised, so it * mustn't affect the simulation state (i.e. the data that is serialised and can affect * the behaviour of the rest of the simulation) else it'll cause out-of-sync errors. */ /** * Returns global information about the current game state. * This is used by the GUI and also by AI scripts. */ GuiInterface.prototype.GetSimulationState = function() { let ret = { "players": [] }; let numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetNumPlayers(); for (let i = 0; i < numPlayers; ++i) { let cmpPlayer = QueryPlayerIDInterface(i); let cmpPlayerEntityLimits = QueryPlayerIDInterface(i, IID_EntityLimits); // Work out what phase we are in let phase = ""; let cmpTechnologyManager = QueryPlayerIDInterface(i, IID_TechnologyManager); if (cmpTechnologyManager) { if (cmpTechnologyManager.IsTechnologyResearched("phase_city")) phase = "city"; else if (cmpTechnologyManager.IsTechnologyResearched("phase_town")) phase = "town"; else if (cmpTechnologyManager.IsTechnologyResearched("phase_village")) phase = "village"; } // store player ally/neutral/enemy data as arrays let allies = []; let mutualAllies = []; let neutrals = []; let enemies = []; for (let j = 0; j < numPlayers; ++j) { allies[j] = cmpPlayer.IsAlly(j); mutualAllies[j] = cmpPlayer.IsMutualAlly(j); neutrals[j] = cmpPlayer.IsNeutral(j); enemies[j] = cmpPlayer.IsEnemy(j); } ret.players.push({ "name": cmpPlayer.GetName(), "civ": cmpPlayer.GetCiv(), "color": cmpPlayer.GetColor(), "controlsAll": cmpPlayer.CanControlAllUnits(), "popCount": cmpPlayer.GetPopulationCount(), "popLimit": cmpPlayer.GetPopulationLimit(), "popMax": cmpPlayer.GetMaxPopulation(), "panelEntities": cmpPlayer.GetPanelEntities(), "resourceCounts": cmpPlayer.GetResourceCounts(), "trainingBlocked": cmpPlayer.IsTrainingBlocked(), "state": cmpPlayer.GetState(), "team": cmpPlayer.GetTeam(), "teamsLocked": cmpPlayer.GetLockTeams(), "cheatsEnabled": cmpPlayer.GetCheatsEnabled(), "disabledTemplates": cmpPlayer.GetDisabledTemplates(), "disabledTechnologies": cmpPlayer.GetDisabledTechnologies(), "hasSharedDropsites": cmpPlayer.HasSharedDropsites(), "hasSharedLos": cmpPlayer.HasSharedLos(), "spyCostMultiplier": cmpPlayer.GetSpyCostMultiplier(), "phase": phase, "isAlly": allies, "isMutualAlly": mutualAllies, "isNeutral": neutrals, "isEnemy": enemies, "entityLimits": cmpPlayerEntityLimits ? cmpPlayerEntityLimits.GetLimits() : null, "entityCounts": cmpPlayerEntityLimits ? cmpPlayerEntityLimits.GetCounts() : null, "entityLimitChangers": cmpPlayerEntityLimits ? cmpPlayerEntityLimits.GetLimitChangers() : null, "researchQueued": cmpTechnologyManager ? cmpTechnologyManager.GetQueuedResearch() : null, "researchStarted": cmpTechnologyManager ? cmpTechnologyManager.GetStartedTechs() : null, "researchedTechs": cmpTechnologyManager ? cmpTechnologyManager.GetResearchedTechs() : null, "classCounts": cmpTechnologyManager ? cmpTechnologyManager.GetClassCounts() : null, "typeCountsByClass": cmpTechnologyManager ? cmpTechnologyManager.GetTypeCountsByClass() : null, "canBarter": Engine.QueryInterface(SYSTEM_ENTITY, IID_Barter).PlayerHasMarket(i), "barterPrices": Engine.QueryInterface(SYSTEM_ENTITY, IID_Barter).GetPrices(i) }); } let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (cmpRangeManager) ret.circularMap = cmpRangeManager.GetLosCircular(); let cmpTerrain = Engine.QueryInterface(SYSTEM_ENTITY, IID_Terrain); if (cmpTerrain) ret.mapSize = cmpTerrain.GetMapSize(); // Add timeElapsed let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); ret.timeElapsed = cmpTimer.GetTime(); // Add ceasefire info let cmpCeasefireManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_CeasefireManager); if (cmpCeasefireManager) { ret.ceasefireActive = cmpCeasefireManager.IsCeasefireActive(); ret.ceasefireTimeRemaining = ret.ceasefireActive ? cmpCeasefireManager.GetCeasefireStartedTime() + cmpCeasefireManager.GetCeasefireTime() - ret.timeElapsed : 0; } // Add cinema path info let cmpCinemaManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_CinemaManager); if (cmpCinemaManager) ret.cinemaPlaying = cmpCinemaManager.IsPlaying(); // Add the game type and allied victory let cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager); ret.victoryConditions = cmpEndGameManager.GetVictoryConditions(); ret.alliedVictory = cmpEndGameManager.GetAlliedVictory(); // Add basic statistics to each player for (let i = 0; i < numPlayers; ++i) { let cmpPlayerStatisticsTracker = QueryPlayerIDInterface(i, IID_StatisticsTracker); if (cmpPlayerStatisticsTracker) ret.players[i].statistics = cmpPlayerStatisticsTracker.GetBasicStatistics(); } return ret; }; /** * Returns global information about the current game state, plus statistics. * This is used by the GUI at the end of a game, in the summary screen. * Note: Amongst statistics, the team exploration map percentage is computed from * scratch, so the extended simulation state should not be requested too often. */ GuiInterface.prototype.GetExtendedSimulationState = function() { // Get basic simulation info let ret = this.GetSimulationState(); // Add statistics to each player let numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetNumPlayers(); for (let i = 0; i < numPlayers; ++i) { let cmpPlayerStatisticsTracker = QueryPlayerIDInterface(i, IID_StatisticsTracker); if (cmpPlayerStatisticsTracker) ret.players[i].sequences = cmpPlayerStatisticsTracker.GetSequences(); } return ret; }; GuiInterface.prototype.GetRenamedEntities = function(player) { if (this.miragedEntities[player]) return this.renamedEntities.concat(this.miragedEntities[player]); return this.renamedEntities; }; GuiInterface.prototype.ClearRenamedEntities = function() { this.renamedEntities = []; this.miragedEntities = []; }; GuiInterface.prototype.AddMiragedEntity = function(player, entity, mirage) { if (!this.miragedEntities[player]) this.miragedEntities[player] = []; this.miragedEntities[player].push({ "entity": entity, "newentity": mirage }); }; /** * Get common entity info, often used in the gui */ GuiInterface.prototype.GetEntityState = function(player, ent) { let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); // All units must have a template; if not then it's a nonexistent entity id let template = cmpTemplateManager.GetCurrentTemplateName(ent); if (!template) return null; let ret = { "id": ent, "player": INVALID_PLAYER, "template": template }; let cmpMirage = Engine.QueryInterface(ent, IID_Mirage); if (cmpMirage) ret.mirage = true; let cmpIdentity = Engine.QueryInterface(ent, IID_Identity); if (cmpIdentity) ret.identity = { "rank": cmpIdentity.GetRank(), "classes": cmpIdentity.GetClassesList(), "visibleClasses": cmpIdentity.GetVisibleClassesList(), "selectionGroupName": cmpIdentity.GetSelectionGroupName(), "canDelete": !cmpIdentity.IsUndeletable() }; let cmpPosition = Engine.QueryInterface(ent, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) ret.position = cmpPosition.GetPosition(); let cmpHealth = QueryMiragedInterface(ent, IID_Health); if (cmpHealth) { ret.hitpoints = cmpHealth.GetHitpoints(); ret.maxHitpoints = cmpHealth.GetMaxHitpoints(); ret.needsRepair = cmpHealth.IsRepairable() && cmpHealth.GetHitpoints() < cmpHealth.GetMaxHitpoints(); ret.needsHeal = !cmpHealth.IsUnhealable(); } let cmpCapturable = QueryMiragedInterface(ent, IID_Capturable); if (cmpCapturable) { ret.capturePoints = cmpCapturable.GetCapturePoints(); ret.maxCapturePoints = cmpCapturable.GetMaxCapturePoints(); } let cmpBuilder = Engine.QueryInterface(ent, IID_Builder); if (cmpBuilder) ret.builder = true; let cmpMarket = QueryMiragedInterface(ent, IID_Market); if (cmpMarket) ret.market = { "land": cmpMarket.HasType("land"), "naval": cmpMarket.HasType("naval"), }; let cmpPack = Engine.QueryInterface(ent, IID_Pack); if (cmpPack) ret.pack = { "packed": cmpPack.IsPacked(), "progress": cmpPack.GetProgress(), }; var cmpUpgrade = Engine.QueryInterface(ent, IID_Upgrade); if (cmpUpgrade) ret.upgrade = { "upgrades": cmpUpgrade.GetUpgrades(), "progress": cmpUpgrade.GetProgress(), "template": cmpUpgrade.GetUpgradingTo() }; let cmpProductionQueue = Engine.QueryInterface(ent, IID_ProductionQueue); if (cmpProductionQueue) ret.production = { "entities": cmpProductionQueue.GetEntitiesList(), "technologies": cmpProductionQueue.GetTechnologiesList(), "techCostMultiplier": cmpProductionQueue.GetTechCostMultiplier(), "queue": cmpProductionQueue.GetQueue() }; let cmpTrader = Engine.QueryInterface(ent, IID_Trader); if (cmpTrader) ret.trader = { "goods": cmpTrader.GetGoods() }; let cmpFoundation = QueryMiragedInterface(ent, IID_Foundation); if (cmpFoundation) ret.foundation = { "numBuilders": cmpFoundation.GetNumBuilders(), "buildTime": cmpFoundation.GetBuildTime() }; let cmpRepairable = QueryMiragedInterface(ent, IID_Repairable); if (cmpRepairable) ret.repairable = { "numBuilders": cmpRepairable.GetNumBuilders(), "buildTime": cmpRepairable.GetBuildTime() }; let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership); if (cmpOwnership) ret.player = cmpOwnership.GetOwner(); let cmpRallyPoint = Engine.QueryInterface(ent, IID_RallyPoint); if (cmpRallyPoint) ret.rallyPoint = { "position": cmpRallyPoint.GetPositions()[0] }; // undefined or {x,z} object let cmpGarrisonHolder = Engine.QueryInterface(ent, IID_GarrisonHolder); if (cmpGarrisonHolder) ret.garrisonHolder = { "entities": cmpGarrisonHolder.GetEntities(), "buffHeal": cmpGarrisonHolder.GetHealRate(), "allowedClasses": cmpGarrisonHolder.GetAllowedClasses(), "capacity": cmpGarrisonHolder.GetCapacity(), "garrisonedEntitiesCount": cmpGarrisonHolder.GetGarrisonedEntitiesCount() }; ret.canGarrison = !!Engine.QueryInterface(ent, IID_Garrisonable); let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); if (cmpUnitAI) ret.unitAI = { "state": cmpUnitAI.GetCurrentState(), "orders": cmpUnitAI.GetOrders(), "hasWorkOrders": cmpUnitAI.HasWorkOrders(), "canGuard": cmpUnitAI.CanGuard(), "isGuarding": cmpUnitAI.IsGuardOf(), "canPatrol": cmpUnitAI.CanPatrol(), "selectableStances": cmpUnitAI.GetSelectableStances(), "isIdle": cmpUnitAI.IsIdle(), }; let cmpGuard = Engine.QueryInterface(ent, IID_Guard); if (cmpGuard) ret.guard = { "entities": cmpGuard.GetEntities(), }; let cmpResourceGatherer = Engine.QueryInterface(ent, IID_ResourceGatherer); if (cmpResourceGatherer) { ret.resourceCarrying = cmpResourceGatherer.GetCarryingStatus(); ret.resourceGatherRates = cmpResourceGatherer.GetGatherRates(); } let cmpGate = Engine.QueryInterface(ent, IID_Gate); if (cmpGate) ret.gate = { "locked": cmpGate.IsLocked(), }; let cmpAlertRaiser = Engine.QueryInterface(ent, IID_AlertRaiser); if (cmpAlertRaiser) ret.alertRaiser = true; let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); ret.visibility = cmpRangeManager.GetLosVisibility(ent, player); let cmpAttack = Engine.QueryInterface(ent, IID_Attack); if (cmpAttack) { let types = cmpAttack.GetAttackTypes(); if (types.length) ret.attack = {}; for (let type of types) { ret.attack[type] = cmpAttack.GetAttackStrengths(type); ret.attack[type].splash = cmpAttack.GetSplashDamage(type); let range = cmpAttack.GetRange(type); ret.attack[type].minRange = range.min; ret.attack[type].maxRange = range.max; let timers = cmpAttack.GetTimers(type); ret.attack[type].prepareTime = timers.prepare; ret.attack[type].repeatTime = timers.repeat; if (type != "Ranged") { // not a ranged attack, set some defaults ret.attack[type].elevationBonus = 0; ret.attack[type].elevationAdaptedRange = ret.attack.maxRange; continue; } ret.attack[type].elevationBonus = range.elevationBonus; if (cmpUnitAI && cmpPosition && cmpPosition.IsInWorld()) { // For units, take the range in front of it, no spread. So angle = 0 ret.attack[type].elevationAdaptedRange = cmpRangeManager.GetElevationAdaptedRange(cmpPosition.GetPosition(), cmpPosition.GetRotation(), range.max, range.elevationBonus, 0); } else if(cmpPosition && cmpPosition.IsInWorld()) { // For buildings, take the average elevation around it. So angle = 2*pi ret.attack[type].elevationAdaptedRange = cmpRangeManager.GetElevationAdaptedRange(cmpPosition.GetPosition(), cmpPosition.GetRotation(), range.max, range.elevationBonus, 2*Math.PI); } else { // not in world, set a default? ret.attack[type].elevationAdaptedRange = ret.attack.maxRange; } } } let cmpArmour = Engine.QueryInterface(ent, IID_DamageReceiver); if (cmpArmour) ret.armour = cmpArmour.GetArmourStrengths(); let cmpBuildingAI = Engine.QueryInterface(ent, IID_BuildingAI); if (cmpBuildingAI) ret.buildingAI = { "defaultArrowCount": cmpBuildingAI.GetDefaultArrowCount(), "maxArrowCount": cmpBuildingAI.GetMaxArrowCount(), "garrisonArrowMultiplier": cmpBuildingAI.GetGarrisonArrowMultiplier(), "garrisonArrowClasses": cmpBuildingAI.GetGarrisonArrowClasses(), "arrowCount": cmpBuildingAI.GetArrowCount() }; if (cmpPosition && cmpPosition.GetTurretParent() != INVALID_ENTITY) ret.turretParent = cmpPosition.GetTurretParent(); let cmpResourceSupply = QueryMiragedInterface(ent, IID_ResourceSupply); if (cmpResourceSupply) ret.resourceSupply = { "isInfinite": cmpResourceSupply.IsInfinite(), "max": cmpResourceSupply.GetMaxAmount(), "amount": cmpResourceSupply.GetCurrentAmount(), "type": cmpResourceSupply.GetType(), "killBeforeGather": cmpResourceSupply.GetKillBeforeGather(), "maxGatherers": cmpResourceSupply.GetMaxGatherers(), "numGatherers": cmpResourceSupply.GetNumGatherers() }; let cmpResourceDropsite = Engine.QueryInterface(ent, IID_ResourceDropsite); if (cmpResourceDropsite) ret.resourceDropsite = { "types": cmpResourceDropsite.GetTypes(), "sharable": cmpResourceDropsite.IsSharable(), "shared": cmpResourceDropsite.IsShared() }; let cmpPromotion = Engine.QueryInterface(ent, IID_Promotion); if (cmpPromotion) ret.promotion = { "curr": cmpPromotion.GetCurrentXp(), "req": cmpPromotion.GetRequiredXp() }; if (!cmpFoundation && cmpIdentity && cmpIdentity.HasClass("BarterMarket")) ret.isBarterMarket = true; let cmpHeal = Engine.QueryInterface(ent, IID_Heal); if (cmpHeal) ret.heal = { "hp": cmpHeal.GetHP(), "range": cmpHeal.GetRange().max, "rate": cmpHeal.GetRate(), "unhealableClasses": cmpHeal.GetUnhealableClasses(), "healableClasses": cmpHeal.GetHealableClasses(), }; let cmpLoot = Engine.QueryInterface(ent, IID_Loot); if (cmpLoot) { ret.loot = cmpLoot.GetResources(); ret.loot.xp = cmpLoot.GetXp(); } let cmpResourceTrickle = Engine.QueryInterface(ent, IID_ResourceTrickle); if (cmpResourceTrickle) ret.resourceTrickle = { "interval": cmpResourceTrickle.GetTimer(), "rates": cmpResourceTrickle.GetRates() }; let cmpUnitMotion = Engine.QueryInterface(ent, IID_UnitMotion); if (cmpUnitMotion) ret.speed = { "walk": cmpUnitMotion.GetWalkSpeed(), - "run": cmpUnitMotion.GetWalkSpeed() * cmpUnitMotion.GetRunSpeedMultiplier() + "run": cmpUnitMotion.GetWalkSpeed() * cmpUnitMotion.GetRunMultiplier() }; return ret; }; GuiInterface.prototype.GetMultipleEntityStates = function(player, ents) { return ents.map(ent => ({ "entId": ent, "state": this.GetEntityState(player, ent) })); }; GuiInterface.prototype.GetAverageRangeForBuildings = function(player, cmd) { let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); let cmpTerrain = Engine.QueryInterface(SYSTEM_ENTITY, IID_Terrain); let rot = { "x": 0, "y": 0, "z": 0 }; let pos = { "x": cmd.x, "y": cmpTerrain.GetGroundLevel(cmd.x, cmd.z), "z": cmd.z }; let elevationBonus = cmd.elevationBonus || 0; let range = cmd.range; return cmpRangeManager.GetElevationAdaptedRange(pos, rot, range, elevationBonus, 2*Math.PI); }; GuiInterface.prototype.GetTemplateData = function(player, templateName) { let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); let template = cmpTemplateManager.GetTemplate(templateName); if (!template) return null; let aurasTemplate = {}; if (!template.Auras) return GetTemplateDataHelper(template, player, aurasTemplate, Resources, DamageTypes); let auraNames = template.Auras._string.split(/\s+/); for (let name of auraNames) aurasTemplate[name] = AuraTemplates.Get(name); return GetTemplateDataHelper(template, player, aurasTemplate, Resources, DamageTypes); }; GuiInterface.prototype.IsTechnologyResearched = function(player, data) { if (!data.tech) return true; let cmpTechnologyManager = QueryPlayerIDInterface(data.player || player, IID_TechnologyManager); if (!cmpTechnologyManager) return false; return cmpTechnologyManager.IsTechnologyResearched(data.tech); }; // Checks whether the requirements for this technology have been met GuiInterface.prototype.CheckTechnologyRequirements = function(player, data) { let cmpTechnologyManager = QueryPlayerIDInterface(data.player || player, IID_TechnologyManager); if (!cmpTechnologyManager) return false; return cmpTechnologyManager.CanResearch(data.tech); }; // Returns technologies that are being actively researched, along with // which entity is researching them and how far along the research is. GuiInterface.prototype.GetStartedResearch = function(player) { let cmpTechnologyManager = QueryPlayerIDInterface(player, IID_TechnologyManager); if (!cmpTechnologyManager) return {}; let ret = {}; for (let tech of cmpTechnologyManager.GetStartedTechs()) { ret[tech] = { "researcher": cmpTechnologyManager.GetResearcher(tech) }; let cmpProductionQueue = Engine.QueryInterface(ret[tech].researcher, IID_ProductionQueue); if (cmpProductionQueue) { ret[tech].progress = cmpProductionQueue.GetQueue()[0].progress; ret[tech].timeRemaining = cmpProductionQueue.GetQueue()[0].timeRemaining; } else { ret[tech].progress = 0; ret[tech].timeRemaining = 0; } } return ret; }; // Returns the battle state of the player. GuiInterface.prototype.GetBattleState = function(player) { let cmpBattleDetection = QueryPlayerIDInterface(player, IID_BattleDetection); if (!cmpBattleDetection) return false; return cmpBattleDetection.GetState(); }; // Returns a list of ongoing attacks against the player. GuiInterface.prototype.GetIncomingAttacks = function(player) { return QueryPlayerIDInterface(player, IID_AttackDetection).GetIncomingAttacks(); }; // Used to show a red square over GUI elements you can't yet afford. GuiInterface.prototype.GetNeededResources = function(player, data) { return QueryPlayerIDInterface(data.player || player).GetNeededResources(data.cost); }; /** * State of the templateData (player dependent): true when some template values have been modified * and need to be reloaded by the gui. */ GuiInterface.prototype.OnTemplateModification = function(msg) { this.templateModified[msg.player] = true; }; GuiInterface.prototype.IsTemplateModified = function(player) { return this.templateModified[player] || false; }; GuiInterface.prototype.ResetTemplateModified = function() { this.templateModified = {}; }; /** * Add a timed notification. * Warning: timed notifacations are serialised * (to also display them on saved games or after a rejoin) * so they should allways be added and deleted in a deterministic way. */ GuiInterface.prototype.AddTimeNotification = function(notification, duration = 10000) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); notification.endTime = duration + cmpTimer.GetTime(); notification.id = ++this.timeNotificationID; // Let all players and observers receive the notification by default if (!notification.players) { notification.players = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetAllPlayers(); notification.players[0] = -1; } this.timeNotifications.push(notification); this.timeNotifications.sort((n1, n2) => n2.endTime - n1.endTime); cmpTimer.SetTimeout(this.entity, IID_GuiInterface, "DeleteTimeNotification", duration, this.timeNotificationID); return this.timeNotificationID; }; GuiInterface.prototype.DeleteTimeNotification = function(notificationID) { this.timeNotifications = this.timeNotifications.filter(n => n.id != notificationID); }; GuiInterface.prototype.GetTimeNotifications = function(player) { let time = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer).GetTime(); // filter on players and time, since the delete timer might be executed with a delay return this.timeNotifications.filter(n => n.players.indexOf(player) != -1 && n.endTime > time); }; GuiInterface.prototype.PushNotification = function(notification) { if (!notification.type || notification.type == "text") this.AddTimeNotification(notification); else this.notifications.push(notification); }; GuiInterface.prototype.GetNotifications = function() { let n = this.notifications; this.notifications = []; return n; }; GuiInterface.prototype.GetAvailableFormations = function(player, wantedPlayer) { return QueryPlayerIDInterface(wantedPlayer).GetFormations(); }; GuiInterface.prototype.GetFormationRequirements = function(player, data) { return GetFormationRequirements(data.formationTemplate); }; GuiInterface.prototype.CanMoveEntsIntoFormation = function(player, data) { return CanMoveEntsIntoFormation(data.ents, data.formationTemplate); }; GuiInterface.prototype.GetFormationInfoFromTemplate = function(player, data) { let cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); let template = cmpTemplateManager.GetTemplate(data.templateName); if (!template || !template.Formation) return {}; return { "name": template.Formation.FormationName, "tooltip": template.Formation.DisabledTooltip || "", "icon": template.Formation.Icon }; }; GuiInterface.prototype.IsFormationSelected = function(player, data) { return data.ents.some(ent => { let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); return cmpUnitAI && cmpUnitAI.GetFormationTemplate() == data.formationTemplate; }); }; GuiInterface.prototype.IsStanceSelected = function(player, data) { for (let ent of data.ents) { let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); if (cmpUnitAI && cmpUnitAI.GetStanceName() == data.stance) return true; } return false; }; GuiInterface.prototype.GetAllBuildableEntities = function(player, cmd) { let buildableEnts = []; for (let ent of cmd.entities) { let cmpBuilder = Engine.QueryInterface(ent, IID_Builder); if (!cmpBuilder) continue; for (let building of cmpBuilder.GetEntitiesList()) if (buildableEnts.indexOf(building) == -1) buildableEnts.push(building); } return buildableEnts; }; GuiInterface.prototype.UpdateDisplayedPlayerColors = function(player, data) { let updateEntityColor = (iids, entities) => { for (let ent of entities) for (let iid of iids) { let cmp = Engine.QueryInterface(ent, iid); if (cmp) cmp.UpdateColor(); } }; let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); let numPlayers = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetNumPlayers(); for (let i = 1; i < numPlayers; ++i) { let cmpPlayer = QueryPlayerIDInterface(i, IID_Player); if (!cmpPlayer) continue; cmpPlayer.SetDisplayDiplomacyColor(data.displayDiplomacyColors); if (data.displayDiplomacyColors) cmpPlayer.SetDiplomacyColor(data.displayedPlayerColors[i]); updateEntityColor(data.showAllStatusBars && (i == player || player == -1) ? [IID_Minimap, IID_RangeOverlayRenderer, IID_RallyPointRenderer, IID_StatusBars] : [IID_Minimap, IID_RangeOverlayRenderer, IID_RallyPointRenderer], cmpRangeManager.GetEntitiesByPlayer(i)); } updateEntityColor([IID_Selectable, IID_StatusBars], data.selected); Engine.QueryInterface(SYSTEM_ENTITY, IID_TerritoryManager).UpdateColors(); }; GuiInterface.prototype.SetSelectionHighlight = function(player, cmd) { let playerColors = {}; // cache of owner -> color map for (let ent of cmd.entities) { let cmpSelectable = Engine.QueryInterface(ent, IID_Selectable); if (!cmpSelectable) continue; // Find the entity's owner's color: let owner = INVALID_PLAYER; let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership); if (cmpOwnership) owner = cmpOwnership.GetOwner(); let color = playerColors[owner]; if (!color) { color = { "r": 1, "g": 1, "b": 1 }; let cmpPlayer = QueryPlayerIDInterface(owner); if (cmpPlayer) color = cmpPlayer.GetDisplayedColor(); playerColors[owner] = color; } cmpSelectable.SetSelectionHighlight({ "r": color.r, "g": color.g, "b": color.b, "a": cmd.alpha }, cmd.selected); let cmpRangeOverlayManager = Engine.QueryInterface(ent, IID_RangeOverlayManager); if (!cmpRangeOverlayManager || player != owner && player != INVALID_PLAYER) continue; cmpRangeOverlayManager.SetEnabled(cmd.selected, this.enabledVisualRangeOverlayTypes, false); } }; GuiInterface.prototype.EnableVisualRangeOverlayType = function(player, data) { this.enabledVisualRangeOverlayTypes[data.type] = data.enabled; }; GuiInterface.prototype.GetEntitiesWithStatusBars = function() { return Array.from(this.entsWithAuraAndStatusBars); }; GuiInterface.prototype.SetStatusBars = function(player, cmd) { let affectedEnts = new Set(); for (let ent of cmd.entities) { let cmpStatusBars = Engine.QueryInterface(ent, IID_StatusBars); if (!cmpStatusBars) continue; cmpStatusBars.SetEnabled(cmd.enabled, cmd.showRank, cmd.showExperience); let cmpAuras = Engine.QueryInterface(ent, IID_Auras); if (!cmpAuras) continue; for (let name of cmpAuras.GetAuraNames()) { if (!cmpAuras.GetOverlayIcon(name)) continue; for (let e of cmpAuras.GetAffectedEntities(name)) affectedEnts.add(e); if (cmd.enabled) this.entsWithAuraAndStatusBars.add(ent); else this.entsWithAuraAndStatusBars.delete(ent); } } for (let ent of affectedEnts) { let cmpStatusBars = Engine.QueryInterface(ent, IID_StatusBars); if (cmpStatusBars) cmpStatusBars.RegenerateSprites(); } }; GuiInterface.prototype.SetRangeOverlays = function(player, cmd) { for (let ent of cmd.entities) { let cmpRangeOverlayManager = Engine.QueryInterface(ent, IID_RangeOverlayManager); if (cmpRangeOverlayManager) cmpRangeOverlayManager.SetEnabled(cmd.enabled, this.enabledVisualRangeOverlayTypes, true); } }; GuiInterface.prototype.GetPlayerEntities = function(player) { return Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).GetEntitiesByPlayer(player); }; GuiInterface.prototype.GetNonGaiaEntities = function() { return Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).GetNonGaiaEntities(); }; /** * Displays the rally points of a given list of entities (carried in cmd.entities). * * The 'cmd' object may carry its own x/z coordinate pair indicating the location where the rally point should * be rendered, in order to support instantaneously rendering a rally point marker at a specified location * instead of incurring a delay while PostNetworkCommand processes the set-rallypoint command (see input.js). * If cmd doesn't carry a custom location, then the position to render the marker at will be read from the * RallyPoint component. */ GuiInterface.prototype.DisplayRallyPoint = function(player, cmd) { let cmpPlayer = QueryPlayerIDInterface(player); // If there are some rally points already displayed, first hide them for (let ent of this.entsRallyPointsDisplayed) { let cmpRallyPointRenderer = Engine.QueryInterface(ent, IID_RallyPointRenderer); if (cmpRallyPointRenderer) cmpRallyPointRenderer.SetDisplayed(false); } this.entsRallyPointsDisplayed = []; // Show the rally points for the passed entities for (let ent of cmd.entities) { let cmpRallyPointRenderer = Engine.QueryInterface(ent, IID_RallyPointRenderer); if (!cmpRallyPointRenderer) continue; // entity must have a rally point component to display a rally point marker // (regardless of whether cmd specifies a custom location) let cmpRallyPoint = Engine.QueryInterface(ent, IID_RallyPoint); if (!cmpRallyPoint) continue; // Verify the owner let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership); if (!(cmpPlayer && cmpPlayer.CanControlAllUnits())) if (!cmpOwnership || cmpOwnership.GetOwner() != player) continue; // If the command was passed an explicit position, use that and // override the real rally point position; otherwise use the real position let pos; if (cmd.x && cmd.z) pos = cmd; else pos = cmpRallyPoint.GetPositions()[0]; // may return undefined if no rally point is set if (pos) { // Only update the position if we changed it (cmd.queued is set) if ("queued" in cmd) if (cmd.queued == true) cmpRallyPointRenderer.AddPosition({ 'x': pos.x, 'y': pos.z }); // AddPosition takes a CFixedVector2D which has X/Y components, not X/Z else cmpRallyPointRenderer.SetPosition({ 'x': pos.x, 'y': pos.z }); // SetPosition takes a CFixedVector2D which has X/Y components, not X/Z // rebuild the renderer when not set (when reading saved game or in case of building update) else if (!cmpRallyPointRenderer.IsSet()) for (let posi of cmpRallyPoint.GetPositions()) cmpRallyPointRenderer.AddPosition({ 'x': posi.x, 'y': posi.z }); cmpRallyPointRenderer.SetDisplayed(true); // remember which entities have their rally points displayed so we can hide them again this.entsRallyPointsDisplayed.push(ent); } } }; GuiInterface.prototype.AddTargetMarker = function(player, cmd) { let ent = Engine.AddLocalEntity(cmd.template); if (!ent) return; let cmpPosition = Engine.QueryInterface(ent, IID_Position); cmpPosition.JumpTo(cmd.x, cmd.z); }; /** * Display the building placement preview. * cmd.template is the name of the entity template, or "" to disable the preview. * cmd.x, cmd.z, cmd.angle give the location. * * Returns result object from CheckPlacement: * { * "success": true iff the placement is valid, else false * "message": message to display in UI for invalid placement, else "" * "parameters": parameters to use in the message * "translateMessage": localisation info * "translateParameters": localisation info * "pluralMessage": we might return a plural translation instead (optional) * "pluralCount": localisation info (optional) * } */ GuiInterface.prototype.SetBuildingPlacementPreview = function(player, cmd) { let result = { "success": false, "message": "", "parameters": {}, "translateMessage": false, "translateParameters": [], }; // See if we're changing template if (!this.placementEntity || this.placementEntity[0] != cmd.template) { // Destroy the old preview if there was one if (this.placementEntity) Engine.DestroyEntity(this.placementEntity[1]); // Load the new template if (cmd.template == "") this.placementEntity = undefined; else this.placementEntity = [cmd.template, Engine.AddLocalEntity("preview|" + cmd.template)]; } if (this.placementEntity) { let ent = this.placementEntity[1]; // Move the preview into the right location let pos = Engine.QueryInterface(ent, IID_Position); if (pos) { pos.JumpTo(cmd.x, cmd.z); pos.SetYRotation(cmd.angle); } let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership); cmpOwnership.SetOwner(player); // Check whether building placement is valid let cmpBuildRestrictions = Engine.QueryInterface(ent, IID_BuildRestrictions); if (!cmpBuildRestrictions) error("cmpBuildRestrictions not defined"); else result = cmpBuildRestrictions.CheckPlacement(); let cmpRangeOverlayManager = Engine.QueryInterface(ent, IID_RangeOverlayManager); if (cmpRangeOverlayManager) cmpRangeOverlayManager.SetEnabled(true, this.enabledVisualRangeOverlayTypes); // Set it to a red shade if this is an invalid location let cmpVisual = Engine.QueryInterface(ent, IID_Visual); if (cmpVisual) { if (cmd.actorSeed !== undefined) cmpVisual.SetActorSeed(cmd.actorSeed); if (!result.success) cmpVisual.SetShadingColor(1.4, 0.4, 0.4, 1); else cmpVisual.SetShadingColor(1, 1, 1, 1); } } return result; }; /** * Previews the placement of a wall between cmd.start and cmd.end, or just the starting piece of a wall if cmd.end is not * specified. Returns an object with information about the list of entities that need to be newly constructed to complete * at least a part of the wall, or false if there are entities required to build at least part of the wall but none of * them can be validly constructed. * * It's important to distinguish between three lists of entities that are at play here, because they may be subsets of one * another depending on things like snapping and whether some of the entities inside them can be validly positioned. * We have: * - The list of entities that previews the wall. This list is usually equal to the entities required to construct the * entire wall. However, if there is snapping to an incomplete tower (i.e. a foundation), it includes extra entities * to preview the completed tower on top of its foundation. * * - The list of entities that need to be newly constructed to build the entire wall. This list is regardless of whether * any of them can be validly positioned. The emphasishere here is on 'newly'; this list does not include any existing * towers at either side of the wall that we snapped to. Or, more generally; it does not include any _entities_ that we * snapped to; we might still snap to e.g. terrain, in which case the towers on either end will still need to be newly * constructed. * * - The list of entities that need to be newly constructed to build at least a part of the wall. This list is the same * as the one above, except that it is truncated at the first entity that cannot be validly positioned. This happens * e.g. if the player tries to build a wall straight through an obstruction. Note that any entities that can be validly * constructed but come after said first invalid entity are also truncated away. * * With this in mind, this method will return false if the second list is not empty, but the third one is. That is, if there * were entities that are needed to build the wall, but none of them can be validly constructed. False is also returned in * case of unexpected errors (typically missing components), and when clearing the preview by passing an empty wallset * argument (see below). Otherwise, it will return an object with the following information: * * result: { * 'startSnappedEnt': ID of the entity that we snapped to at the starting side of the wall. Currently only supports towers. * 'endSnappedEnt': ID of the entity that we snapped to at the (possibly truncated) ending side of the wall. Note that this * can only be set if no truncation of the second list occurs; if we snapped to an entity at the ending side * but the wall construction was truncated before we could reach it, it won't be set here. Currently only * supports towers. * 'pieces': Array with the following data for each of the entities in the third list: * [{ * 'template': Template name of the entity. * 'x': X coordinate of the entity's position. * 'z': Z coordinate of the entity's position. * 'angle': Rotation around the Y axis of the entity (in radians). * }, * ...] * 'cost': { The total cost required for constructing all the pieces as listed above. * 'food': ..., * 'wood': ..., * 'stone': ..., * 'metal': ..., * 'population': ..., * 'populationBonus': ..., * } * } * * @param cmd.wallSet Object holding the set of wall piece template names. Set to an empty value to clear the preview. * @param cmd.start Starting point of the wall segment being created. * @param cmd.end (Optional) Ending point of the wall segment being created. If not defined, it is understood that only * the starting point of the wall is available at this time (e.g. while the player is still in the process * of picking a starting point), and that therefore only the first entity in the wall (a tower) should be * previewed. * @param cmd.snapEntities List of candidate entities to snap the start and ending positions to. */ GuiInterface.prototype.SetWallPlacementPreview = function(player, cmd) { let wallSet = cmd.wallSet; let start = { "pos": cmd.start, "angle": 0, "snapped": false, // did the start position snap to anything? "snappedEnt": INVALID_ENTITY, // if we snapped, was it to an entity? if yes, holds that entity's ID }; let end = { "pos": cmd.end, "angle": 0, "snapped": false, // did the start position snap to anything? "snappedEnt": INVALID_ENTITY, // if we snapped, was it to an entity? if yes, holds that entity's ID }; // -------------------------------------------------------------------------------- // do some entity cache management and check for snapping if (!this.placementWallEntities) this.placementWallEntities = {}; if (!wallSet) { // we're clearing the preview, clear the entity cache and bail for (let tpl in this.placementWallEntities) { for (let ent of this.placementWallEntities[tpl].entities) Engine.DestroyEntity(ent); this.placementWallEntities[tpl].numUsed = 0; this.placementWallEntities[tpl].entities = []; // keep template data around } return false; } // Move all existing cached entities outside of the world and reset their use count for (let tpl in this.placementWallEntities) { for (let ent of this.placementWallEntities[tpl].entities) { let pos = Engine.QueryInterface(ent, IID_Position); if (pos) pos.MoveOutOfWorld(); } this.placementWallEntities[tpl].numUsed = 0; } // Create cache entries for templates we haven't seen before for (let type in wallSet.templates) { if (type == "curves") continue; let tpl = wallSet.templates[type]; if (!(tpl in this.placementWallEntities)) { this.placementWallEntities[tpl] = { "numUsed": 0, "entities": [], "templateData": this.GetTemplateData(player, tpl), }; // ensure that the loaded template data contains a wallPiece component if (!this.placementWallEntities[tpl].templateData.wallPiece) { error("[SetWallPlacementPreview] No WallPiece component found for wall set template '" + tpl + "'"); return false; } } } // prevent division by zero errors further on if the start and end positions are the same if (end.pos && (start.pos.x === end.pos.x && start.pos.z === end.pos.z)) end.pos = undefined; // See if we need to snap the start and/or end coordinates to any of our list of snap entities. Note that, despite the list // of snapping candidate entities, it might still snap to e.g. terrain features. Use the "ent" key in the returned snapping // data to determine whether it snapped to an entity (if any), and to which one (see GetFoundationSnapData). if (cmd.snapEntities) { let snapRadius = this.placementWallEntities[wallSet.templates.tower].templateData.wallPiece.length * 0.5; // determined through trial and error let startSnapData = this.GetFoundationSnapData(player, { "x": start.pos.x, "z": start.pos.z, "template": wallSet.templates.tower, "snapEntities": cmd.snapEntities, "snapRadius": snapRadius, }); if (startSnapData) { start.pos.x = startSnapData.x; start.pos.z = startSnapData.z; start.angle = startSnapData.angle; start.snapped = true; if (startSnapData.ent) start.snappedEnt = startSnapData.ent; } if (end.pos) { let endSnapData = this.GetFoundationSnapData(player, { "x": end.pos.x, "z": end.pos.z, "template": wallSet.templates.tower, "snapEntities": cmd.snapEntities, "snapRadius": snapRadius, }); if (endSnapData) { end.pos.x = endSnapData.x; end.pos.z = endSnapData.z; end.angle = endSnapData.angle; end.snapped = true; if (endSnapData.ent) end.snappedEnt = endSnapData.ent; } } } // clear the single-building preview entity (we'll be rolling our own) this.SetBuildingPlacementPreview(player, { "template": "" }); // -------------------------------------------------------------------------------- // calculate wall placement and position preview entities let result = { "pieces": [], "cost": { "population": 0, "populationBonus": 0, "time": 0 }, }; for (let res of Resources.GetCodes()) result.cost[res] = 0; let previewEntities = []; if (end.pos) previewEntities = GetWallPlacement(this.placementWallEntities, wallSet, start, end); // see helpers/Walls.js // For wall placement, we may (and usually do) need to have wall pieces overlap each other more than would // otherwise be allowed by their obstruction shapes. However, during this preview phase, this is not so much of // an issue, because all preview entities have their obstruction components deactivated, meaning that their // obstruction shapes do not register in the simulation and hence cannot affect it. This implies that the preview // entities cannot be found to obstruct each other, which largely solves the issue of overlap between wall pieces. // Note that they will still be obstructed by existing shapes in the simulation (that have the BLOCK_FOUNDATION // flag set), which is what we want. The only exception to this is when snapping to existing towers (or // foundations thereof); the wall segments that connect up to these will be found to be obstructed by the // existing tower/foundation, and be shaded red to indicate that they cannot be placed there. To prevent this, // we manually set the control group of the outermost wall pieces equal to those of the snapped-to towers, so // that they are free from mutual obstruction (per definition of obstruction control groups). This is done by // assigning them an extra "controlGroup" field, which we'll then set during the placement loop below. // Additionally, in the situation that we're snapping to merely a foundation of a tower instead of a fully // constructed one, we'll need an extra preview entity for the starting tower, which also must not be obstructed // by the foundation it snaps to. if (start.snappedEnt && start.snappedEnt != INVALID_ENTITY) { let startEntObstruction = Engine.QueryInterface(start.snappedEnt, IID_Obstruction); if (previewEntities.length > 0 && startEntObstruction) previewEntities[0].controlGroups = [startEntObstruction.GetControlGroup()]; // if we're snapping to merely a foundation, add an extra preview tower and also set it to the same control group let startEntState = this.GetEntityState(player, start.snappedEnt); if (startEntState.foundation) { let cmpPosition = Engine.QueryInterface(start.snappedEnt, IID_Position); if (cmpPosition) previewEntities.unshift({ "template": wallSet.templates.tower, "pos": start.pos, "angle": cmpPosition.GetRotation().y, "controlGroups": [startEntObstruction ? startEntObstruction.GetControlGroup() : undefined], "excludeFromResult": true, // preview only, must not appear in the result }); } } else { // Didn't snap to an existing entity, add the starting tower manually. To prevent odd-looking rotation jumps // when shift-clicking to build a wall, reuse the placement angle that was last seen on a validly positioned // wall piece. // To illustrate the last point, consider what happens if we used some constant instead, say, 0. Issuing the // build command for a wall is asynchronous, so when the preview updates after shift-clicking, the wall piece // foundations are not registered yet in the simulation. This means they cannot possibly be picked in the list // of candidate entities for snapping. In the next preview update, we therefore hit this case, and would rotate // the preview to 0 radians. Then, after one or two simulation updates or so, the foundations register and // onSimulationUpdate in session.js updates the preview again. It first grabs a new list of snapping candidates, // which this time does include the new foundations; so we snap to the entity, and rotate the preview back to // the foundation's angle. // The result is a noticeable rotation to 0 and back, which is undesirable. So, for a split second there until // the simulation updates, we fake it by reusing the last angle and hope the player doesn't notice. previewEntities.unshift({ "template": wallSet.templates.tower, "pos": start.pos, "angle": previewEntities.length > 0 ? previewEntities[0].angle : this.placementWallLastAngle }); } if (end.pos) { // Analogous to the starting side case above if (end.snappedEnt && end.snappedEnt != INVALID_ENTITY) { let endEntObstruction = Engine.QueryInterface(end.snappedEnt, IID_Obstruction); // Note that it's possible for the last entity in previewEntities to be the same as the first, i.e. the // same wall piece snapping to both a starting and an ending tower. And it might be more common than you would // expect; the allowed overlap between wall segments and towers facilitates this to some degree. To deal with // the possibility of dual initial control groups, we use a '.controlGroups' array rather than a single // '.controlGroup' property. Note that this array can only ever have 0, 1 or 2 elements (checked at a later time). if (previewEntities.length > 0 && endEntObstruction) { previewEntities[previewEntities.length-1].controlGroups = previewEntities[previewEntities.length-1].controlGroups || []; previewEntities[previewEntities.length-1].controlGroups.push(endEntObstruction.GetControlGroup()); } // if we're snapping to a foundation, add an extra preview tower and also set it to the same control group let endEntState = this.GetEntityState(player, end.snappedEnt); if (endEntState.foundation) { let cmpPosition = Engine.QueryInterface(end.snappedEnt, IID_Position); if (cmpPosition) previewEntities.push({ "template": wallSet.templates.tower, "pos": end.pos, "angle": cmpPosition.GetRotation().y, "controlGroups": [endEntObstruction ? endEntObstruction.GetControlGroup() : undefined], "excludeFromResult": true }); } } else previewEntities.push({ "template": wallSet.templates.tower, "pos": end.pos, "angle": previewEntities.length > 0 ? previewEntities[previewEntities.length-1].angle : this.placementWallLastAngle }); } let cmpTerrain = Engine.QueryInterface(SYSTEM_ENTITY, IID_Terrain); if (!cmpTerrain) { error("[SetWallPlacementPreview] System Terrain component not found"); return false; } let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (!cmpRangeManager) { error("[SetWallPlacementPreview] System RangeManager component not found"); return false; } // Loop through the preview entities, and construct the subset of them that need to be, and can be, validly constructed // to build at least a part of the wall (meaning that the subset is truncated after the first entity that needs to be, // but cannot validly be, constructed). See method-level documentation for more details. let allPiecesValid = true; let numRequiredPieces = 0; // number of entities that are required to build the entire wall, regardless of validity for (let i = 0; i < previewEntities.length; ++i) { let entInfo = previewEntities[i]; let ent = null; let tpl = entInfo.template; let tplData = this.placementWallEntities[tpl].templateData; let entPool = this.placementWallEntities[tpl]; if (entPool.numUsed >= entPool.entities.length) { // allocate new entity ent = Engine.AddLocalEntity("preview|" + tpl); entPool.entities.push(ent); } else // reuse an existing one ent = entPool.entities[entPool.numUsed]; if (!ent) { error("[SetWallPlacementPreview] Failed to allocate or reuse preview entity of template '" + tpl + "'"); continue; } // move piece to right location // TODO: consider reusing SetBuildingPlacementReview for this, enhanced to be able to deal with multiple entities let cmpPosition = Engine.QueryInterface(ent, IID_Position); if (cmpPosition) { cmpPosition.JumpTo(entInfo.pos.x, entInfo.pos.z); cmpPosition.SetYRotation(entInfo.angle); // if this piece is a tower, then it should have a Y position that is at least as high as its surrounding pieces if (tpl === wallSet.templates.tower) { let terrainGroundPrev = null; let terrainGroundNext = null; if (i > 0) terrainGroundPrev = cmpTerrain.GetGroundLevel(previewEntities[i-1].pos.x, previewEntities[i-1].pos.z); if (i < previewEntities.length - 1) terrainGroundNext = cmpTerrain.GetGroundLevel(previewEntities[i+1].pos.x, previewEntities[i+1].pos.z); if (terrainGroundPrev != null || terrainGroundNext != null) { let targetY = Math.max(terrainGroundPrev, terrainGroundNext); cmpPosition.SetHeightFixed(targetY); } } } let cmpObstruction = Engine.QueryInterface(ent, IID_Obstruction); if (!cmpObstruction) { error("[SetWallPlacementPreview] Preview entity of template '" + tpl + "' does not have an Obstruction component"); continue; } // Assign any predefined control groups. Note that there can only be 0, 1 or 2 predefined control groups; if there are // more, we've made a programming error. The control groups are assigned from the entInfo.controlGroups array on a // first-come first-served basis; the first value in the array is always assigned as the primary control group, and // any second value as the secondary control group. // By default, we reset the control groups to their standard values. Remember that we're reusing entities; if we don't // reset them, then an ending wall segment that was e.g. at one point snapped to an existing tower, and is subsequently // reused as a non-snapped ending wall segment, would no longer be capable of being obstructed by the same tower it was // once snapped to. let primaryControlGroup = ent; let secondaryControlGroup = INVALID_ENTITY; if (entInfo.controlGroups && entInfo.controlGroups.length > 0) { if (entInfo.controlGroups.length > 2) { error("[SetWallPlacementPreview] Encountered preview entity of template '" + tpl + "' with more than 2 initial control groups"); break; } primaryControlGroup = entInfo.controlGroups[0]; if (entInfo.controlGroups.length > 1) secondaryControlGroup = entInfo.controlGroups[1]; } cmpObstruction.SetControlGroup(primaryControlGroup); cmpObstruction.SetControlGroup2(secondaryControlGroup); // check whether this wall piece can be validly positioned here let validPlacement = false; let cmpOwnership = Engine.QueryInterface(ent, IID_Ownership); cmpOwnership.SetOwner(player); // Check whether it's in a visible or fogged region // TODO: should definitely reuse SetBuildingPlacementPreview, this is just straight up copy/pasta let visible = cmpRangeManager.GetLosVisibility(ent, player) != "hidden"; if (visible) { let cmpBuildRestrictions = Engine.QueryInterface(ent, IID_BuildRestrictions); if (!cmpBuildRestrictions) { error("[SetWallPlacementPreview] cmpBuildRestrictions not defined for preview entity of template '" + tpl + "'"); continue; } // TODO: Handle results of CheckPlacement validPlacement = cmpBuildRestrictions && cmpBuildRestrictions.CheckPlacement().success; // If a wall piece has two control groups, it's likely a segment that spans // between two existing towers. To avoid placing a duplicate wall segment, // check for collisions with entities that share both control groups. if (validPlacement && entInfo.controlGroups && entInfo.controlGroups.length > 1) validPlacement = cmpObstruction.CheckDuplicateFoundation(); } allPiecesValid = allPiecesValid && validPlacement; // The requirement below that all pieces so far have to have valid positions, rather than only this single one, // ensures that no more foundations will be placed after a first invalidly-positioned piece. (It is possible // for pieces past some invalidly-positioned ones to still have valid positions, e.g. if you drag a wall // through and past an existing building). // Additionally, the excludeFromResult flag is set for preview entities that were manually added to be placed // on top of foundations of incompleted towers that we snapped to; they must not be part of the result. if (!entInfo.excludeFromResult) ++numRequiredPieces; if (allPiecesValid && !entInfo.excludeFromResult) { result.pieces.push({ "template": tpl, "x": entInfo.pos.x, "z": entInfo.pos.z, "angle": entInfo.angle, }); this.placementWallLastAngle = entInfo.angle; // grab the cost of this wall piece and add it up (note; preview entities don't have their Cost components // copied over, so we need to fetch it from the template instead). // TODO: we should really use a Cost object or at least some utility functions for this, this is mindless // boilerplate that's probably duplicated in tons of places. for (let res of Resources.GetCodes().concat(["population", "populationBonus", "time"])) result.cost[res] += tplData.cost[res]; } let canAfford = true; let cmpPlayer = QueryPlayerIDInterface(player, IID_Player); if (cmpPlayer && cmpPlayer.GetNeededResources(result.cost)) canAfford = false; let cmpVisual = Engine.QueryInterface(ent, IID_Visual); if (cmpVisual) { if (!allPiecesValid || !canAfford) cmpVisual.SetShadingColor(1.4, 0.4, 0.4, 1); else cmpVisual.SetShadingColor(1, 1, 1, 1); } ++entPool.numUsed; } // If any were entities required to build the wall, but none of them could be validly positioned, return failure // (see method-level documentation). if (numRequiredPieces > 0 && result.pieces.length == 0) return false; if (start.snappedEnt && start.snappedEnt != INVALID_ENTITY) result.startSnappedEnt = start.snappedEnt; // We should only return that we snapped to an entity if all pieces up until that entity can be validly constructed, // i.e. are included in result.pieces (see docs for the result object). if (end.pos && end.snappedEnt && end.snappedEnt != INVALID_ENTITY && allPiecesValid) result.endSnappedEnt = end.snappedEnt; return result; }; /** * Given the current position {data.x, data.z} of an foundation of template data.template, returns the position and angle to snap * it to (if necessary/useful). * * @param data.x The X position of the foundation to snap. * @param data.z The Z position of the foundation to snap. * @param data.template The template to get the foundation snapping data for. * @param data.snapEntities Optional; list of entity IDs to snap to if {data.x, data.z} is within a circle of radius data.snapRadius * around the entity. Only takes effect when used in conjunction with data.snapRadius. * When this option is used and the foundation is found to snap to one of the entities passed in this list * (as opposed to e.g. snapping to terrain features), then the result will contain an additional key "ent", * holding the ID of the entity that was snapped to. * @param data.snapRadius Optional; when used in conjunction with data.snapEntities, indicates the circle radius around an entity that * {data.x, data.z} must be located within to have it snap to that entity. */ GuiInterface.prototype.GetFoundationSnapData = function(player, data) { let template = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager).GetTemplate(data.template); if (!template) { warn("[GetFoundationSnapData] Failed to load template '" + data.template + "'"); return false; } if (data.snapEntities && data.snapRadius && data.snapRadius > 0) { // see if {data.x, data.z} is inside the snap radius of any of the snap entities; and if so, to which it is closest // (TODO: break unlikely ties by choosing the lowest entity ID) let minDist2 = -1; let minDistEntitySnapData = null; let radius2 = data.snapRadius * data.snapRadius; for (let ent of data.snapEntities) { let cmpPosition = Engine.QueryInterface(ent, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) continue; let pos = cmpPosition.GetPosition(); let dist2 = (data.x - pos.x) * (data.x - pos.x) + (data.z - pos.z) * (data.z - pos.z); if (dist2 > radius2) continue; if (minDist2 < 0 || dist2 < minDist2) { minDist2 = dist2; minDistEntitySnapData = { "x": pos.x, "z": pos.z, "angle": cmpPosition.GetRotation().y, "ent": ent }; } } if (minDistEntitySnapData != null) return minDistEntitySnapData; } if (template.BuildRestrictions.PlacementType == "shore") { let angle = GetDockAngle(template, data.x, data.z); if (angle !== undefined) return { "x": data.x, "z": data.z, "angle": angle }; } return false; }; GuiInterface.prototype.PlaySound = function(player, data) { if (!data.entity) return; PlaySound(data.name, data.entity); }; /** * Find any idle units. * * @param data.idleClasses Array of class names to include. * @param data.prevUnit The previous idle unit, if calling a second time to iterate through units. May be left undefined. * @param data.limit The number of idle units to return. May be left undefined (will return all idle units). * @param data.excludeUnits Array of units to exclude. * * Returns an array of idle units. * If multiple classes were supplied, and multiple items will be returned, the items will be sorted by class. */ GuiInterface.prototype.FindIdleUnits = function(player, data) { let idleUnits = []; // The general case is that only the 'first' idle unit is required; filtering would examine every unit. // This loop imitates a grouping/aggregation on the first matching idle class. let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); for (let entity of cmpRangeManager.GetEntitiesByPlayer(player)) { let filtered = this.IdleUnitFilter(entity, data.idleClasses, data.excludeUnits); if (!filtered.idle) continue; // If the entity is in the 'current' (first, 0) bucket on a resumed search, it must be after the "previous" unit, if any. // By adding to the 'end', there is no pause if the series of units loops. var bucket = filtered.bucket; if(bucket == 0 && data.prevUnit && entity <= data.prevUnit) bucket = data.idleClasses.length; if (!idleUnits[bucket]) idleUnits[bucket] = []; idleUnits[bucket].push(entity); // If enough units have been collected in the first bucket, go ahead and return them. if (data.limit && bucket == 0 && idleUnits[0].length == data.limit) return idleUnits[0]; } let reduced = idleUnits.reduce((prev, curr) => prev.concat(curr), []); if (data.limit && reduced.length > data.limit) return reduced.slice(0, data.limit); return reduced; }; /** * Discover if the player has idle units. * * @param data.idleClasses Array of class names to include. * @param data.excludeUnits Array of units to exclude. * * Returns a boolean of whether the player has any idle units */ GuiInterface.prototype.HasIdleUnits = function(player, data) { let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); return cmpRangeManager.GetEntitiesByPlayer(player).some(unit => this.IdleUnitFilter(unit, data.idleClasses, data.excludeUnits).idle); }; /** * Whether to filter an idle unit * * @param unit The unit to filter. * @param idleclasses Array of class names to include. * @param excludeUnits Array of units to exclude. * * Returns an object with the following fields: * - idle - true if the unit is considered idle by the filter, false otherwise. * - bucket - if idle, set to the index of the first matching idle class, undefined otherwise. */ GuiInterface.prototype.IdleUnitFilter = function(unit, idleClasses, excludeUnits) { let cmpUnitAI = Engine.QueryInterface(unit, IID_UnitAI); if (!cmpUnitAI || !cmpUnitAI.IsIdle() || cmpUnitAI.IsGarrisoned()) return { "idle": false }; let cmpIdentity = Engine.QueryInterface(unit, IID_Identity); if(!cmpIdentity) return { "idle": false }; let bucket = idleClasses.findIndex(elem => MatchesClassList(cmpIdentity.GetClassesList(), elem)); if (bucket == -1 || excludeUnits.indexOf(unit) > -1) return { "idle": false }; return { "idle": true, "bucket": bucket }; }; GuiInterface.prototype.GetTradingRouteGain = function(player, data) { if (!data.firstMarket || !data.secondMarket) return null; return CalculateTraderGain(data.firstMarket, data.secondMarket, data.template); }; GuiInterface.prototype.GetTradingDetails = function(player, data) { let cmpEntityTrader = Engine.QueryInterface(data.trader, IID_Trader); if (!cmpEntityTrader || !cmpEntityTrader.CanTrade(data.target)) return null; let firstMarket = cmpEntityTrader.GetFirstMarket(); let secondMarket = cmpEntityTrader.GetSecondMarket(); let result = null; if (data.target === firstMarket) { result = { "type": "is first", "hasBothMarkets": cmpEntityTrader.HasBothMarkets() }; if (cmpEntityTrader.HasBothMarkets()) result.gain = cmpEntityTrader.GetGoods().amount; } else if (data.target === secondMarket) { result = { "type": "is second", "gain": cmpEntityTrader.GetGoods().amount, }; } else if (!firstMarket) { result = { "type": "set first" }; } else if (!secondMarket) { result = { "type": "set second", "gain": cmpEntityTrader.CalculateGain(firstMarket, data.target), }; } else { // Else both markets are not null and target is different from them result = { "type": "set first" }; } return result; }; GuiInterface.prototype.CanAttack = function(player, data) { let cmpAttack = Engine.QueryInterface(data.entity, IID_Attack); return cmpAttack && cmpAttack.CanAttack(data.target, data.types || undefined); }; /* * Returns batch build time. */ GuiInterface.prototype.GetBatchTime = function(player, data) { let cmpProductionQueue = Engine.QueryInterface(data.entity, IID_ProductionQueue); if (!cmpProductionQueue) return 0; return cmpProductionQueue.GetBatchTime(data.batchSize); }; GuiInterface.prototype.IsMapRevealed = function(player) { return Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).GetLosRevealAll(player); }; GuiInterface.prototype.SetPathfinderDebugOverlay = function(player, enabled) { Engine.QueryInterface(SYSTEM_ENTITY, IID_Pathfinder).SetDebugOverlay(enabled); }; GuiInterface.prototype.SetPathfinderHierDebugOverlay = function(player, enabled) { Engine.QueryInterface(SYSTEM_ENTITY, IID_Pathfinder).SetHierDebugOverlay(enabled); }; GuiInterface.prototype.SetObstructionDebugOverlay = function(player, enabled) { Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager).SetDebugOverlay(enabled); }; GuiInterface.prototype.SetMotionDebugOverlay = function(player, data) { for (let ent of data.entities) { let cmpUnitMotion = Engine.QueryInterface(ent, IID_UnitMotion); if (cmpUnitMotion) cmpUnitMotion.SetDebugOverlay(data.enabled); } }; GuiInterface.prototype.SetRangeDebugOverlay = function(player, enabled) { Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager).SetDebugOverlay(enabled); }; GuiInterface.prototype.GetTraderNumber = function(player) { let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); let traders = cmpRangeManager.GetEntitiesByPlayer(player).filter(e => Engine.QueryInterface(e, IID_Trader)); let landTrader = { "total": 0, "trading": 0, "garrisoned": 0 }; let shipTrader = { "total": 0, "trading": 0 }; for (let ent of traders) { let cmpIdentity = Engine.QueryInterface(ent, IID_Identity); let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); if (!cmpIdentity || !cmpUnitAI) continue; if (cmpIdentity.HasClass("Ship")) { ++shipTrader.total; if (cmpUnitAI.order && cmpUnitAI.order.type == "Trade") ++shipTrader.trading; } else { ++landTrader.total; if (cmpUnitAI.order && cmpUnitAI.order.type == "Trade") ++landTrader.trading; if (cmpUnitAI.order && cmpUnitAI.order.type == "Garrison") { let holder = cmpUnitAI.order.data.target; let cmpHolderUnitAI = Engine.QueryInterface(holder, IID_UnitAI); if (cmpHolderUnitAI && cmpHolderUnitAI.order && cmpHolderUnitAI.order.type == "Trade") ++landTrader.garrisoned; } } } return { "landTrader": landTrader, "shipTrader": shipTrader }; }; GuiInterface.prototype.GetTradingGoods = function(player) { return QueryPlayerIDInterface(player).GetTradingGoods(); }; GuiInterface.prototype.OnGlobalEntityRenamed = function(msg) { this.renamedEntities.push(msg); }; // List the GuiInterface functions that can be safely called by GUI scripts. // (GUI scripts are non-deterministic and untrusted, so these functions must be // appropriately careful. They are called with a first argument "player", which is // trusted and indicates the player associated with the current client; no data should // be returned unless this player is meant to be able to see it.) let exposedFunctions = { "GetSimulationState": 1, "GetExtendedSimulationState": 1, "GetRenamedEntities": 1, "ClearRenamedEntities": 1, "GetEntityState": 1, "GetMultipleEntityStates": 1, "GetAverageRangeForBuildings": 1, "GetTemplateData": 1, "IsTechnologyResearched": 1, "CheckTechnologyRequirements": 1, "GetStartedResearch": 1, "GetBattleState": 1, "GetIncomingAttacks": 1, "GetNeededResources": 1, "GetNotifications": 1, "GetTimeNotifications": 1, "GetAvailableFormations": 1, "GetFormationRequirements": 1, "CanMoveEntsIntoFormation": 1, "IsFormationSelected": 1, "GetFormationInfoFromTemplate": 1, "IsStanceSelected": 1, "UpdateDisplayedPlayerColors": 1, "SetSelectionHighlight": 1, "GetAllBuildableEntities": 1, "SetStatusBars": 1, "GetPlayerEntities": 1, "GetNonGaiaEntities": 1, "DisplayRallyPoint": 1, "AddTargetMarker": 1, "SetBuildingPlacementPreview": 1, "SetWallPlacementPreview": 1, "GetFoundationSnapData": 1, "PlaySound": 1, "FindIdleUnits": 1, "HasIdleUnits": 1, "GetTradingRouteGain": 1, "GetTradingDetails": 1, "CanAttack": 1, "GetBatchTime": 1, "IsMapRevealed": 1, "SetPathfinderDebugOverlay": 1, "SetPathfinderHierDebugOverlay": 1, "SetObstructionDebugOverlay": 1, "SetMotionDebugOverlay": 1, "SetRangeDebugOverlay": 1, "EnableVisualRangeOverlayType": 1, "SetRangeOverlays": 1, "GetTraderNumber": 1, "GetTradingGoods": 1, "IsTemplateModified": 1, "ResetTemplateModified": 1 }; GuiInterface.prototype.ScriptCall = function(player, name, args) { if (exposedFunctions[name]) return this[name](player, args); throw new Error("Invalid GuiInterface Call name \""+name+"\""); }; Engine.RegisterSystemComponentType(IID_GuiInterface, "GuiInterface", GuiInterface); Index: ps/trunk/binaries/data/mods/public/simulation/components/UnitAI.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/UnitAI.js (revision 22276) +++ ps/trunk/binaries/data/mods/public/simulation/components/UnitAI.js (revision 22277) @@ -1,5968 +1,5968 @@ 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 } }; // See ../helpers/FSM.js for some documentation of this FSM specification syntax UnitAI.prototype.UnitFsmSpec = { // Default event handlers: "MoveCompleted": function() { // ignore spurious movement messages // (these can happen when stopping moving at the same time // as switching states) }, "MoveStarted": function() { // ignore spurious movement messages }, "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 }, "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 }, // 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.IsTurret()) { 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; } var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); cmpUnitMotion.MoveToFormationOffset(msg.data.target, msg.data.x, msg.data.z); 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 (!IsOwnedByAllyOfEntity(this.entity, msg.data.target) && !Engine.QueryInterface(SYSTEM_ENTITY, IID_CeasefireManager).IsCeasefireActive() || this.IsPacking() || this.CanPack() || this.IsTurret()) { this.FinishOrder(); return; } // Move a tile outside the building let range = 4; if (this.MoveToTargetRangeExplicit(msg.data.target, range, range)) { // We've started walking to the given point this.SetNextState("INDIVIDUAL.WALKING"); } else { // We are already at the target, or can't move at all this.FinishOrder(); } }, // 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 this.SetNextState("INDIVIDUAL.IDLE"); }, "Order.Walk": function(msg) { // Let players move captured domestic animals around if (this.IsAnimal() && !this.IsDomestic() || this.IsTurret()) { 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); if (!this.order.data.max) this.MoveToPoint(this.order.data.x, this.order.data.z); else this.MoveToPointRange(this.order.data.x, this.order.data.z, this.order.data.min, this.order.data.max); 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.IsTurret()) { 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); this.MoveToPoint(this.order.data.x, this.order.data.z); 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.IsTurret()) { 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; } var ok = this.MoveToTarget(this.order.data.target); if (ok) { // We've started walking to the given point if (this.IsAnimal()) this.SetNextState("ANIMAL.WALKING"); else this.SetNextState("INDIVIDUAL.WALKING"); } else { // We are already at the target, or can't move at all this.StopMoving(); this.FinishOrder(); } }, "Order.PickupUnit": function(msg) { var cmpGarrisonHolder = Engine.QueryInterface(this.entity, IID_GarrisonHolder); if (!cmpGarrisonHolder || cmpGarrisonHolder.IsFull()) { this.FinishOrder(); return; } // Check if we need to move TODO implement a better way to know if we are on the shoreline var needToMove = true; var 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; } // TODO: what if the units are on a cliff ? the ship will go below the cliff // and the units won't be able to garrison. Should go to the nearest (accessible) shore if (needToMove && this.MoveToTarget(this.order.data.target)) { this.SetNextState("INDIVIDUAL.PICKUP.APPROACHING"); } else { // We are already at the target, or can't move at all this.StopMoving(); this.SetNextState("INDIVIDUAL.PICKUP.LOADING"); } }, "Order.Guard": function(msg) { if (!this.AddGuard(this.order.data.target)) { this.FinishOrder(); return; } if (this.MoveToTargetRangeExplicit(this.isGuardOf, 0, this.guardRange)) this.SetNextState("INDIVIDUAL.GUARD.ESCORTING"); else this.SetNextState("INDIVIDUAL.GUARD.GUARDING"); }, "Order.Flee": function(msg) { // We use the distance between the entities to account for ranged attacks var distance = DistanceBetweenEntities(this.entity, this.order.data.target) + (+this.template.FleeDistance); var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion.MoveToTargetRange(this.order.data.target, distance, -1)) { // We've started fleeing from the given target if (this.IsAnimal()) this.SetNextState("ANIMAL.FLEEING"); else this.SetNextState("INDIVIDUAL.FLEEING"); } else { // We are already at the target, or can't move at all this.StopMoving(); this.FinishOrder(); } }, "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; // If we are already at the target, try attacking it from here if (this.CheckTargetAttackRange(this.order.data.target, this.order.data.attackType)) { this.StopMoving(); // 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; } if (this.order.data.attackType == this.oldAttackType) { if (this.IsAnimal()) this.SetNextState("ANIMAL.COMBAT.ATTACKING"); else this.SetNextState("INDIVIDUAL.COMBAT.ATTACKING"); } else { 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.IsTurret()) { 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; } // Try to move within attack range if (this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType)) { // We've started walking to the given point if (this.IsAnimal()) this.SetNextState("ANIMAL.COMBAT.APPROACHING"); else this.SetNextState("INDIVIDUAL.COMBAT.APPROACHING"); return; } // We can't reach the target, and can't move towards it, // so abandon this attack order this.FinishOrder(); }, "Order.Patrol": function(msg) { if (this.IsAnimal() || this.IsTurret()) { this.FinishOrder(); return; } if (this.CanPack()) { this.PushOrderFront("Pack", { "force": true }); return; } this.MoveToPoint(this.order.data.x, this.order.data.z); 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.StopMoving(); 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; } // Try to move within heal range if (this.MoveToTargetRange(this.order.data.target, IID_Heal)) { // We've started walking to the given point this.SetNextState("INDIVIDUAL.HEAL.APPROACHING"); return; } // We can't reach the target, and can't move towards it, // so abandon this heal order this.FinishOrder(); }, "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); } else { // We couldn't move there, or the target moved away this.FinishOrder(); } return; } this.PushOrderFront("Attack", { "target": this.order.data.target, "force": !!this.order.data.force, "hunting": true, "allowCapture": false }); return; } // Try to move within range if (this.MoveToTargetRange(this.order.data.target, IID_ResourceGatherer)) { // We've started walking to the given point this.SetNextState("INDIVIDUAL.GATHER.APPROACHING"); } else { // We are already at the target, or can't move at all, // so try gathering it from here. // TODO: need better handling of the can't-reach-target case this.StopMoving(); this.SetNextState("INDIVIDUAL.GATHER.GATHERING"); } }, "Order.GatherNearPosition": function(msg) { // Move the unit to the position to gather from. this.MoveToPoint(this.order.data.x, this.order.data.z); this.SetNextState("INDIVIDUAL.GATHER.WALKING"); }, "Order.ReturnResource": function(msg) { // Check if the dropsite is already in range if (this.CheckTargetRange(this.order.data.target, IID_ResourceGatherer) && this.CanReturnResource(this.order.data.target, true)) { var cmpResourceDropsite = Engine.QueryInterface(this.order.data.target, IID_ResourceDropsite); if (cmpResourceDropsite) { // Dump any resources we can var dropsiteTypes = cmpResourceDropsite.GetTypes(); Engine.QueryInterface(this.entity, IID_ResourceGatherer).CommitResources(dropsiteTypes); // 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; } } // Try to move to the dropsite if (this.MoveToTargetRange(this.order.data.target, IID_ResourceGatherer)) { // We've started walking to the target this.SetNextState("INDIVIDUAL.RETURNRESOURCE.APPROACHING"); return; } // Oops, we can't reach the dropsite. // Maybe we should try to pick another dropsite, to find an // accessible one? // For now, just give up. this.StopMoving(); this.FinishOrder(); return; }, "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; if (this.MoveToMarket(this.order.data.target)) // We've started walking to the next market this.SetNextState("TRADE.APPROACHINGMARKET"); else this.FinishOrder(); }, "Order.Repair": function(msg) { // Try to move within range if (this.MoveToTargetRange(this.order.data.target, IID_Builder)) { // We've started walking to the given point this.SetNextState("INDIVIDUAL.REPAIR.APPROACHING"); } else { // We are already at the target, or can't move at all, // so try repairing it from here. // TODO: need better handling of the can't-reach-target case this.StopMoving(); this.SetNextState("INDIVIDUAL.REPAIR.REPAIRING"); } }, "Order.Garrison": function(msg) { if (this.IsTurret()) { this.SetNextState("IDLE"); return; } else if (this.IsGarrisoned()) { 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.MoveToGarrisonRange(this.order.data.target)) { this.SetNextState("INDIVIDUAL.GARRISON.APPROACHING"); } else { // We do a range check before actually garrisoning this.StopMoving(); this.SetNextState("INDIVIDUAL.GARRISON.GARRISONED"); } }, "Order.Ungarrison": function() { this.FinishOrder(); this.isGarrisoned = false; }, "Order.Cheering": function(msg) { this.SetNextState("INDIVIDUAL.CHEERING"); }, "Order.Pack": function(msg) { if (this.CanPack()) { this.StopMoving(); this.SetNextState("INDIVIDUAL.PACKING"); } }, "Order.Unpack": function(msg) { if (this.CanUnpack()) { this.StopMoving(); 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.MoveToPoint(this.order.data.x, this.order.data.z); this.SetNextState("WALKING"); }, "Order.WalkAndFight": function(msg) { this.CallMemberFunction("SetHeldPosition", [msg.data.x, msg.data.z]); this.MoveToPoint(this.order.data.x, this.order.data.z); this.SetNextState("WALKINGANDFIGHTING"); }, "Order.MoveIntoFormation": function(msg) { this.CallMemberFunction("SetHeldPosition", [msg.data.x, msg.data.z]); this.MoveToPoint(this.order.data.x, this.order.data.z); this.SetNextState("FORMING"); }, // Only used by other orders to walk there in formation "Order.WalkToTargetRange": function(msg) { if (this.MoveToTargetRangeExplicit(this.order.data.target, this.order.data.min, this.order.data.max)) this.SetNextState("WALKING"); else this.FinishOrder(); }, "Order.WalkToTarget": function(msg) { if (this.MoveToTarget(this.order.data.target)) this.SetNextState("WALKING"); else this.FinishOrder(); }, "Order.WalkToPointRange": function(msg) { if (this.MoveToPointRange(this.order.data.x, this.order.data.z, this.order.data.min, this.order.data.max)) this.SetNextState("WALKING"); else this.FinishOrder(); }, "Order.Patrol": function(msg) { this.CallMemberFunction("SetHeldPosition", [msg.data.x, msg.data.z]); this.MoveToPoint(this.order.data.x, this.order.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) { if (!this.IsAttackingAsFormation()) this.CallMemberFunction("Stop", [false]); this.StopMoving(); this.FinishOrder(); }, "Order.Attack": function(msg) { var target = msg.data.target; var allowCapture = msg.data.allowCapture; var cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI); if (cmpTargetUnitAI && cmpTargetUnitAI.IsFormationMember()) target = cmpTargetUnitAI.GetFormationController(); var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); // Check if we are already in range, otherwise walk there if (!this.CheckTargetAttackRange(target, target)) { if (this.TargetIsAlive(target) && this.CheckTargetVisible(target)) { if (this.MoveToTargetAttackRange(target, target)) { this.SetNextState("COMBAT.APPROACHING"); return; } } this.FinishOrder(); return; } this.CallMemberFunction("Attack", [target, allowCapture, false]); if (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 { // Out of range; move there in formation if (this.MoveToGarrisonRange(msg.data.target)) { 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); } else { // We couldn't move there, or the target moved away this.FinishOrder(); } return; } this.PushOrderFront("Attack", { "target": msg.data.target, "force": !!msg.data.force, "hunting": true, "allowCapture": false }); 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) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(false); }, "MoveStarted": function() { let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); } }, "WALKING": { "MoveStarted": function(msg) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); }, "MoveCompleted": function(msg) { if (this.FinishOrder()) this.CallMemberFunction("ResetFinishOrder", []); }, }, "WALKINGANDFIGHTING": { "enter": function(msg) { this.StartTimer(0, 1000); }, "Timer": function(msg) { // check if there are no enemies to attack this.FindWalkAndFightTargets(); }, "leave": function(msg) { this.StopTimer(); }, "MoveStarted": function(msg) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); }, "MoveCompleted": function(msg) { if (this.FinishOrder()) this.CallMemberFunction("ResetFinishOrder", []); }, }, "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; } 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); }, "Timer": function(msg) { // Check if there are no enemies to attack this.FindWalkAndFightTargets(); }, "leave": function(msg) { this.StopTimer(); delete this.patrolStartPosOrder; }, "MoveStarted": function(msg) { let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); }, "MoveCompleted": function() { /** * 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":{ "enter": function() { // If the garrisonholder should pickup, warn it so it can take needed action var 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 }); } }, "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; } }, "APPROACHING": { "MoveStarted": function(msg) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); }, "MoveCompleted": function(msg) { this.SetNextState("GARRISONING"); }, }, "GARRISONING": { "enter": function() { // If a pickup has been requested, cancel it as it will be requested by members if (this.pickup) { Engine.PostMessage(this.pickup, MT_PickupCanceled, { "entity": this.entity }); delete this.pickup; } this.CallMemberFunction("Garrison", [this.order.data.target, false]); this.SetNextState("MEMBER"); }, }, }, "FORMING": { "MoveStarted": function(msg) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, false); }, "MoveCompleted": function(msg) { if (this.FinishOrder()) { this.CallMemberFunction("ResetFinishOrder", []); return; } var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.FindInPosition(); } }, "COMBAT": { "APPROACHING": { "MoveStarted": function(msg) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(true); cmpFormation.MoveMembersIntoFormation(true, true); }, "MoveCompleted": function(msg) { var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); this.CallMemberFunction("Attack", [this.order.data.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) { var target = this.order.data.target; var allowCapture = this.order.data.allowCapture; // Check if we are already in range, otherwise walk there if (!this.CheckTargetAttackRange(target, 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; } var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); // TODO fix the rearranging while attacking as formation cmpFormation.SetRearrange(!this.IsAttackingAsFormation()); cmpFormation.MoveMembersIntoFormation(false, false); this.StartTimer(200, 200); return false; }, "Timer": function(msg) { var target = this.order.data.target; var allowCapture = this.order.data.allowCapture; // Check if we are already in range, otherwise walk there if (!this.CheckTargetAttackRange(target, 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); }, }, }, "MEMBER": { // Wait for individual members to finish "enter": function(msg) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.SetRearrange(false); this.StopMoving(); this.StartTimer(1000, 1000); }, "Timer": function(msg) { // Have all members finished the task? if (!this.TestAllMemberFunction("HasFinishedOrder", [])) return; this.CallMemberFunction("ResetFinishOrder", []); // Execute the next order if (this.FinishOrder()) { // if WalkAndFight order, look for new target before moving again if (this.IsWalkingAndFighting()) this.FindWalkAndFightTargets(); return; } }, "leave": function(msg) { this.StopTimer(); let cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); cmpFormation.MoveToMembersCenter(); }, }, }, // States for entities moving as part of a formation: "FORMATIONMEMBER": { "FormationLeave": function(msg) { // We're not in a formation anymore, so no need to track this. this.finishedOrder = false; // Stop moving as soon as the formation disbands this.StopMoving(); // 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 if (this.IsAnimal()) this.SetNextState("ANIMAL.IDLE"); else 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 (!IsOwnedByAllyOfEntity(this.entity, msg.data.target) && !Engine.QueryInterface(SYSTEM_ENTITY, IID_CeasefireManager).IsCeasefireActive() || this.IsPacking() || this.CanPack() || this.IsTurret()) { this.FinishOrder(); return; } // Move a tile outside the building let range = 4; if (this.MoveToTargetRangeExplicit(msg.data.target, range, range)) { // We've started walking to the given point this.SetNextState("WALKINGTOPOINT"); } else { // We are already at the target, or can't move at all this.FinishOrder(); } }, "IDLE": { "enter": function() { if (this.IsAnimal()) this.SetNextState("ANIMAL.IDLE"); else this.SetNextState("INDIVIDUAL.IDLE"); return true; }, }, "WALKING": { "enter": function() { var cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); var cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (cmpFormation && cmpVisual) { cmpVisual.ReplaceMoveAnimation("walk", cmpFormation.GetFormationAnimation(this.entity, "walk")); cmpVisual.ReplaceMoveAnimation("run", cmpFormation.GetFormationAnimation(this.entity, "run")); } this.SelectAnimation("move"); }, // Occurs when the unit has reached its destination and the controller // is done moving. The controller is notified. "MoveCompleted": function(msg) { // We can only finish this order if the move was really completed. if (!msg.data.error && this.FinishOrder()) return; var cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (cmpVisual) { cmpVisual.ResetMoveAnimation("walk"); cmpVisual.ResetMoveAnimation("run"); } var cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) cmpFormation.SetInPosition(this.entity); }, }, // Special case used by Order.LeaveFoundation "WALKINGTOPOINT": { "enter": function() { var cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) cmpFormation.UnsetInPosition(this.entity); this.SelectAnimation("move"); }, "MoveCompleted": function() { 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"); }, "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.GetHitpoints() < cmpHealth.GetMaxHitpoints()) { 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 var animationName = "idle"; if (this.IsFormationMember()) { var cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) animationName = cmpFormation.GetFormationAnimation(this.entity, animationName); } this.SelectAnimation(animationName); // If we have some orders, it is because we are in an intermediary state // from FinishOrder (SetNextState("IDLE") is only executed when we get // a ProcessMessage), and thus we should not start another order which could // put us in a weird state if (this.orderQueue.length > 0 && !this.IsGarrisoned()) return false; // If the unit is guarding/escorting, go back to its duty if (this.isGuardOf) { this.Guard(this.isGuardOf, false); return true; } // The GUI and AI want to know when a unit is idle, but we don't // want to send frequent spurious messages if the unit's only // idle for an instant and will quickly go off and do something else. // So we'll set a timer here and only report the idle event if we // remain idle this.StartTimer(1000); // 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 true; // (abort the FSM transition since we may have already switched state) // 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 LosRangeUpdate.) if (this.FindNewTargets()) return true; // (abort the FSM transition since we may have already switched state) // Nobody to attack - stay in idle return false; }, "leave": function() { var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losRangeQuery) cmpRangeManager.DisableActiveQuery(this.losRangeQuery); if (this.losHealRangeQuery) cmpRangeManager.DisableActiveQuery(this.losHealRangeQuery); this.StopTimer(); if (this.isIdle) { this.isIdle = false; Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle }); } }, "LosRangeUpdate": function(msg) { if (this.GetStance().targetVisibleEnemies) { // Start attacking one of the newly-seen enemy (if any) this.AttackEntitiesByPreference(msg.data.added); } }, "LosHealRangeUpdate": function(msg) { this.RespondToHealableEntities(msg.data.added); }, "MoveStarted": function() { this.SelectAnimation("move"); }, "MoveCompleted": function() { this.SelectAnimation("idle"); }, "Timer": function(msg) { if (!this.isIdle) { this.isIdle = true; Engine.PostMessage(this.entity, MT_UnitIdleChanged, { "idle": this.isIdle }); } }, }, "WALKING": { "enter": function() { this.SelectAnimation("move"); }, "MoveCompleted": function() { this.FinishOrder(); }, }, "WALKINGANDFIGHTING": { "enter": function() { // Show weapons rather than carried resources. this.SetAnimationVariant("combat"); this.StartTimer(0, 1000); this.SelectAnimation("move"); }, "Timer": function(msg) { this.FindWalkAndFightTargets(); }, "leave": function(msg) { this.StopTimer(); this.SetDefaultAnimationVariant(); }, "MoveCompleted": function() { 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.FinishOrder(); return; } 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"); this.SelectAnimation("move"); }, "leave": function() { this.StopTimer(); delete this.patrolStartPosOrder; this.SetDefaultAnimationVariant(); }, "Timer": function(msg) { this.FindWalkAndFightTargets(); }, "MoveCompleted": function() { 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() { // Show weapons rather than carried resources. this.SetAnimationVariant("combat"); this.StartTimer(0, 1000); this.SelectAnimation("move"); this.SetHeldPositionOnEntity(this.isGuardOf); return false; }, "Timer": function(msg) { // Check the target is alive if (!this.TargetIsAlive(this.isGuardOf)) { this.StopMoving(); this.FinishOrder(); return; } this.SetHeldPositionOnEntity(this.isGuardOf); }, "leave": function(msg) { - this.ResetMoveSpeed(); + this.ResetSpeedMultiplier(); this.StopTimer(); this.SetDefaultAnimationVariant(); }, "MoveStarted": function(msg) { // Adapt the speed to the one of the target if needed var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion.IsInTargetRange(this.isGuardOf, 0, 3*this.guardRange)) { var cmpUnitAI = Engine.QueryInterface(this.isGuardOf, IID_UnitAI); if (cmpUnitAI) { var speed = cmpUnitAI.GetWalkSpeed(); if (speed < this.GetWalkSpeed()) - this.SetMoveSpeedRatio(speed / this.GetWalkSpeed()); + this.SetSpeedMultiplier(speed / this.GetWalkSpeed()); } } }, "MoveCompleted": function() { - this.ResetMoveSpeed(); + this.ResetSpeedMultiplier(); if (!this.MoveToTargetRangeExplicit(this.isGuardOf, 0, this.guardRange)) this.SetNextState("GUARDING"); }, }, "GUARDING": { "enter": function() { this.StartTimer(1000, 1000); this.SetHeldPositionOnEntity(this.entity); this.SetAnimationVariant("combat"); this.SelectAnimation("idle"); return false; }, "LosRangeUpdate": function(msg) { // Start attacking one of the newly-seen enemy (if any) 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 if (this.MoveToTargetRangeExplicit(this.isGuardOf, 0, this.guardRange)) this.SetNextState("ESCORTING"); else { // 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.GetHitpoints() < cmpHealth.GetMaxHitpoints())) { 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() { this.PlaySound("panic"); // Run quickly this.SelectAnimation("move"); - this.SetMoveSpeedRatio(this.GetRunMultiplier()); + this.SetSpeedMultiplier(this.GetRunMultiplier()); }, "HealthChanged": function() { - this.SetMoveSpeedRatio(this.GetRunMultiplier()); + this.SetSpeedMultiplier(this.GetRunMultiplier()); }, "leave": function() { - this.ResetMoveSpeed(); + this.ResetSpeedMultiplier(); }, "MoveCompleted": function() { // When we've run far enough, stop fleeing 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]); }, "APPROACHING": { "enter": function() { // Show weapons rather than carried resources. this.SetAnimationVariant("combat"); this.SelectAnimation("move"); this.StartTimer(1000, 1000); }, "leave": function() { // Show carried resources when walking. this.SetDefaultAnimationVariant(); 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(); } }, "MoveCompleted": function() { 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; } else this.SetNextState("ATTACKING"); } else { if (this.MoveToTargetAttackRange(this.order.data.target, this.order.data.attackType)) { this.SetNextState("APPROACHING"); } else { // Give up this.FinishOrder(); } } }, }, "ATTACKING": { "enter": function() { var target = this.order.data.target; var 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; } // Check the target is still alive and attackable if (this.CanAttack(target) && !this.CheckTargetAttackRange(target, this.order.data.attackType)) { // 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; } if (this.MoveToTargetAttackRange(target, this.order.data.attackType)) { this.SetNextState("COMBAT.CHASING"); return; } } } var 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. var prepare = this.attackTimers.prepare; if (this.lastAttacked) { var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); var repeatLeft = this.lastAttacked + this.attackTimers.repeat - cmpTimer.GetTime(); prepare = Math.max(prepare, repeatLeft); } this.oldAttackType = this.order.data.attackType; // add prefix + no capital first letter for attackType var animationName = "attack_" + this.order.data.attackType.toLowerCase(); if (this.IsFormationMember()) { var cmpFormation = Engine.QueryInterface(this.formationController, IID_Formation); if (cmpFormation) animationName = cmpFormation.GetFormationAnimation(this.entity, animationName); } this.SetAnimationVariant("combat"); this.SelectAnimation(animationName); this.SetAnimationSync(prepare, this.attackTimers.repeat); this.StartTimer(prepare, this.attackTimers.repeat); // TODO: we should probably only bother syncing projectile attacks, not melee // If using a non-default prepare time, re-sync the animation when the timer runs. this.resyncAnimation = (prepare != this.attackTimers.prepare) ? true : false; this.FaceTowardsTarget(this.order.data.target); var cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); if (cmpBuildingAI) cmpBuildingAI.SetUnitAITarget(this.order.data.target); }, "leave": function() { var cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); if (cmpBuildingAI) cmpBuildingAI.SetUnitAITarget(0); this.StopTimer(); this.SetDefaultAnimationVariant(); }, "Timer": function(msg) { var target = this.order.data.target; var cmpFormation = Engine.QueryInterface(target, IID_Formation); // if the target is a formation, save the attacking formation, and pick a member if (cmpFormation) { var thisObject = this; var filter = function(t) { return thisObject.CanAttack(t); }; this.order.data.formationTarget = target; target = cmpFormation.GetClosestMember(this.entity, filter); this.order.data.target = target; } // Check the target is still alive and attackable if (this.CanAttack(target)) { // If we are hunting, first update the target position of the gather order so we know where will be the killed animal if (this.order.data.hunting && this.orderQueue[1] && this.orderQueue[1].data.lastPos) { var cmpPosition = Engine.QueryInterface(this.order.data.target, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) { // Store the initial position, so that we can find the rest of the herd later if (!this.orderQueue[1].data.initPos) this.orderQueue[1].data.initPos = this.orderQueue[1].data.lastPos; this.orderQueue[1].data.lastPos = cmpPosition.GetPosition(); // We still know where the animal is, so we shouldn't give up before going there this.orderQueue[1].data.secondTry = undefined; } } var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); this.lastAttacked = cmpTimer.GetTime() - msg.lateness; this.FaceTowardsTarget(target); // BuildingAI has it's own attack-routine var cmpBuildingAI = Engine.QueryInterface(this.entity, IID_BuildingAI); if (!cmpBuildingAI) { let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); cmpAttack.PerformAttack(this.order.data.attackType, target); } // Check we can still reach the target for the next attack if (this.CheckTargetAttackRange(target, this.order.data.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; } if (this.MoveToTargetRange(target, IID_Attack, this.order.data.attackType)) { this.SetNextState("COMBAT.CHASING"); return; } } } // if we're targetting a formation, find a new member of that formation var cmpTargetFormation = Engine.QueryInterface(this.order.data.formationTarget || INVALID_ENTITY, IID_Formation); // if there is no target, it means previously searching for the target inside the target formation failed, so don't repeat the search if (target && cmpTargetFormation) { this.order.data.target = this.order.data.formationTarget; this.TimerHandler(msg.data, msg.lateness); return; } // 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 ennemies around before moving again if (this.FinishOrder()) { if (this.IsWalkingAndFighting()) this.FindWalkAndFightTargets(); return; } // See if we can switch to a new nearby enemy if (this.FindNewTargets()) { // Attempt to immediately re-enter the timer function, to avoid wasting the attack. // Packable units may have switched to PACKING state, thus canceling the timer and having order.data.attackType undefined. if (this.orderQueue.length > 0 && this.orderQueue[0].data && this.orderQueue[0].data.attackType && this.orderQueue[0].data.attackType == this.oldAttackType) this.TimerHandler(msg.data, msg.lateness); return; } // Return to our original position if (this.GetStance().respondHoldGround) this.WalkToHeldPosition(); }, // 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]); }, }, "CHASING": { "enter": function() { // Show weapons rather than carried resources. this.SetAnimationVariant("combat"); this.SelectAnimation("move"); var cmpUnitAI = Engine.QueryInterface(this.order.data.target, IID_UnitAI); if (cmpUnitAI && cmpUnitAI.IsFleeing()) { // Run after a fleeing target - this.SetMoveSpeedRatio(this.GetRunMultiplier()); + this.SetSpeedMultiplier(this.GetRunMultiplier()); } this.StartTimer(1000, 1000); }, "leave": function() { this.ResetMoveSpeed(); // Show carried resources when walking. this.SetDefaultAnimationVariant(); 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(); } }, "MoveCompleted": function() { this.SetNextState("ATTACKING"); }, }, }, "GATHER": { "APPROACHING": { "enter": function() { this.SelectAnimation("move"); this.gatheringTarget = this.order.data.target; // temporary, deleted in "leave". // check that we can gather from the resource we're supposed to gather from. var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); var cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); var cmpMirage = Engine.QueryInterface(this.gatheringTarget, IID_Mirage); if ((!cmpMirage || !cmpMirage.Mirages(IID_ResourceSupply)) && (!cmpSupply || !cmpSupply.AddGatherer(cmpOwnership.GetOwner(), this.entity))) { // Save the current order's data in case we need it later var oldType = this.order.data.type; var oldTarget = this.order.data.target; var oldTemplate = this.order.data.template; // Try the next queued order if there is any if (this.FinishOrder()) return true; // Try to find another nearby target of the same specific type // Also don't switch to a different type of huntable animal var nearby = this.FindNearbyResource(function(ent, type, template) { return ( ent != oldTarget && ((type.generic == "treasure" && oldType.generic == "treasure") || (type.specific == oldType.specific && (type.specific != "meat" || oldTemplate == template))) ); }, oldTarget); if (nearby) { this.PerformGather(nearby, false, false); return true; } else { // It's probably better in this case, to avoid units getting stuck around a dropsite // in a "Target is far away, full, nearby are no good resources, return to dropsite" loop // to order it to GatherNear the resource position. var cmpPosition = Engine.QueryInterface(oldTarget, IID_Position); if (cmpPosition) { var pos = cmpPosition.GetPosition(); this.GatherNearPosition(pos.x, pos.z, oldType, oldTemplate); return true; } else { // we're kind of stuck here. Return resource. var nearby = this.FindNearestDropsite(oldType.generic); if (nearby) { this.PushOrderFront("ReturnResource", { "target": nearby, "force": false }); return true; } } } return true; } return false; }, "MoveCompleted": function(msg) { if (msg.data.error) { // We failed to reach the target // remove us from the list of entities gathering from Resource. var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); var cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); if (cmpSupply && cmpOwnership) cmpSupply.RemoveGatherer(this.entity, cmpOwnership.GetOwner()); else if (cmpSupply) cmpSupply.RemoveGatherer(this.entity); // Save the current order's data in case we need it later var oldType = this.order.data.type; var oldTarget = this.order.data.target; var oldTemplate = this.order.data.template; // Try the next queued order if there is any if (this.FinishOrder()) return; // Try to find another nearby target of the same specific type // Also don't switch to a different type of huntable animal var nearby = this.FindNearbyResource(function(ent, type, template) { return ( ent != oldTarget && ((type.generic == "treasure" && oldType.generic == "treasure") || (type.specific == oldType.specific && (type.specific != "meat" || oldTemplate == template))) ); }); if (nearby) { this.PerformGather(nearby, false, false); return; } // Couldn't find anything else. Just try this one again, // maybe we'll succeed next time this.PerformGather(oldTarget, false, false); return; } // We reached the target - start gathering from it now this.SetNextState("GATHERING"); }, "leave": function() { // don't use ownership because this is called after a conversion/resignation // and the ownership would be invalid then. var cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); if (cmpSupply) cmpSupply.RemoveGatherer(this.entity); delete this.gatheringTarget; this.SetDefaultAnimationVariant(); }, }, // Walking to a good place to gather resources near, used by GatherNearPosition "WALKING": { "enter": function() { this.SelectAnimation("move"); }, "MoveCompleted": function(msg) { var resourceType = this.order.data.type; var resourceTemplate = this.order.data.template; // Try to find another nearby target of the same specific type // Also don't switch to a different type of huntable animal var nearby = this.FindNearbyResource(function(ent, type, template) { return ( (type.generic == "treasure" && resourceType.generic == "treasure") || (type.specific == resourceType.specific && (type.specific != "meat" || resourceTemplate == template)) ); }); // If there is a nearby resource start gathering if (nearby) { this.PerformGather(nearby, false, false); return; } // Couldn't find nearby resources, so give up if (this.FinishOrder()) return; // Nothing better to do: go back to dropsite var nearby = this.FindNearestDropsite(resourceType.generic); if (nearby) { this.PushOrderFront("ReturnResource", { "target": nearby, "force": false }); return; } // No dropsites, just give up }, }, "GATHERING": { "enter": function() { this.gatheringTarget = this.order.data.target; // deleted in "leave". // Check if the resource is full. if (this.gatheringTarget) { // Check that we can gather from the resource we're supposed to gather from. // Will only be added if we're not already in. var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); var cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); if (!cmpSupply || !cmpSupply.AddGatherer(cmpOwnership.GetOwner(), this.entity)) { this.gatheringTarget = INVALID_ENTITY; this.StartTimer(0); return false; } } // 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. var cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); var 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)) { // Let the Timer logic handle this this.StartTimer(0); return false; } // 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. var offset = 1000/rate; var repeat = offset; this.StartTimer(offset, repeat); // 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.SetDefaultAnimationVariant(); var typename = "gather_" + this.order.data.type.specific; this.SelectAnimation(typename); } 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. var cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); if (cmpSupply) cmpSupply.RemoveGatherer(this.entity); delete this.gatheringTarget; // Show the carried resource, if we've gathered anything. this.SetDefaultAnimationVariant(); }, "Timer": function(msg) { var resourceTemplate = this.order.data.template; var resourceType = this.order.data.type; var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership) return; var cmpSupply = Engine.QueryInterface(this.gatheringTarget, IID_ResourceSupply); if (cmpSupply && cmpSupply.IsAvailable(cmpOwnership.GetOwner(), this.entity)) { // Check we can still reach and gather from the target if (this.CheckTargetRange(this.gatheringTarget, IID_ResourceGatherer) && this.CanGather(this.gatheringTarget)) { // Gather the resources: var 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(); // Collect from the target var status = cmpResourceGatherer.PerformGather(this.gatheringTarget); // If we've collected as many resources as possible, // return to the nearest dropsite if (status.filled) { var nearby = this.FindNearestDropsite(resourceType.generic); if (nearby) { // (Keep this Gather order on the stack so we'll // continue gathering after returning) this.PushOrderFront("ReturnResource", { "target": nearby, "force": false }); return; } // Oh no, couldn't find any drop sites. Give up on gathering. this.FinishOrder(); return; } // We can gather more from this target, do so in the next timer if (!status.exhausted) return; } else { // Try to follow the target if (this.MoveToTargetRange(this.gatheringTarget, IID_ResourceGatherer)) { this.SetNextState("APPROACHING"); return; } // Can't reach the target, or it doesn't exist any more // We want to carry on gathering resources in the same area as // the old one. So try to get close to the old resource's // last known position var maxRange = 8; // get close but not too close if (this.order.data.lastPos && this.MoveToPointRange(this.order.data.lastPos.x, this.order.data.lastPos.z, 0, maxRange)) { this.SetNextState("APPROACHING"); return; } } } // We're already in range, can't get anywhere near it or the target is exhausted. var herdPos = this.order.data.initPos; // 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 var 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 nearby = this.FindNearestDropsite(resourceType.generic); if (nearby) this.orderQueue.splice(1, 0, { "type": "ReturnResource", "data": { "target": nearby, "force": false } }); } if (this.FinishOrder()) return; // No remaining orders - pick a useful default behaviour // Try to find a new resource of the same specific type near our current position: // Also don't switch to a different type of huntable animal var nearby = this.FindNearbyResource(function(ent, type, template) { return ( (type.generic == "treasure" && resourceType.generic == "treasure") || (type.specific == resourceType.specific && (type.specific != "meat" || resourceTemplate == template)) ); }); if (nearby) { this.PerformGather(nearby, false, false); return; } // If hunting, try to go to the initial herd position to see if we are more lucky if (herdPos) { this.GatherNearPosition(herdPos.x, herdPos.z, resourceType, resourceTemplate); return; } // 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 var nearby = this.FindNearestDropsite(resourceType.generic); if (nearby) { this.PushOrderFront("ReturnResource", { "target": nearby, "force": false }); return; } // No dropsites - just give up }, }, }, "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() { this.SelectAnimation("move"); this.StartTimer(1000, 1000); }, "leave": function() { this.StopTimer(); }, "Timer": function(msg) { if (this.ShouldAbandonChase(this.order.data.target, this.order.data.force, IID_Heal, null)) { this.StopMoving(); this.FinishOrder(); // Return to our original position if (this.GetStance().respondHoldGround) this.WalkToHeldPosition(); } }, "MoveCompleted": function() { this.SetNextState("HEALING"); }, }, "HEALING": { "enter": function() { var 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) ? true : false; this.FaceTowardsTarget(this.order.data.target); }, "leave": function() { this.StopTimer(); }, "Timer": function(msg) { var target = this.order.data.target; // Check the target is still alive and healable if (this.TargetIsAlive(target) && this.CanHeal(target)) { // Check if we can still reach the target if (this.CheckTargetRange(target, IID_Heal)) { var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); this.lastHealed = cmpTimer.GetTime() - msg.lateness; this.FaceTowardsTarget(target); var cmpHeal = Engine.QueryInterface(this.entity, IID_Heal); cmpHeal.PerformHeal(target); if (this.resyncAnimation) { this.SetAnimationSync(this.healTimers.repeat, this.healTimers.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; } if (this.MoveToTargetRange(target, IID_Heal)) { this.SetNextState("HEAL.CHASING"); return; } } } // Can't reach it, healed to max hp or doesn't exist any more - give up if (this.FinishOrder()) return; // Heal another one if (this.FindNewHealTargets()) return; // Return to our original position if (this.GetStance().respondHoldGround) this.WalkToHeldPosition(); }, }, "CHASING": { "enter": function() { this.SelectAnimation("move"); this.StartTimer(1000, 1000); }, "leave": function() { this.StopTimer(); }, "Timer": function(msg) { if (this.ShouldAbandonChase(this.order.data.target, this.order.data.force, IID_Heal, null)) { this.StopMoving(); this.FinishOrder(); // Return to our original position if (this.GetStance().respondHoldGround) this.WalkToHeldPosition(); } }, "MoveCompleted": function() { this.SetNextState("HEALING"); }, }, }, // Returning to dropsite "RETURNRESOURCE": { "APPROACHING": { "enter": function() { this.SelectAnimation("move"); }, "MoveCompleted": function() { // Switch back to idle animation to guarantee we won't // get stuck with the carry animation after stopping moving this.SelectAnimation("idle"); // Check the dropsite is in range and we can return our resource there // (we didn't get stopped before reaching it) if (this.CheckTargetRange(this.order.data.target, IID_ResourceGatherer) && this.CanReturnResource(this.order.data.target, true)) { var cmpResourceDropsite = Engine.QueryInterface(this.order.data.target, IID_ResourceDropsite); if (cmpResourceDropsite) { // Dump any resources we can var dropsiteTypes = cmpResourceDropsite.GetTypes(); var cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); cmpResourceGatherer.CommitResources(dropsiteTypes); // 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; } } // The dropsite was destroyed, or we couldn't reach it, or ownership changed // Look for a new one. var cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); var genericType = cmpResourceGatherer.GetMainCarryingType(); var 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() { this.SelectAnimation("move"); }, "MoveCompleted": function() { 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() { this.SelectAnimation("move"); }, "MoveCompleted": function() { 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; this.repairTarget = this.order.data.target; // temporary, deleted in "leave". // 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)) { if (this.MoveToTargetRange(this.repairTarget, IID_Builder)) this.SetNextState("APPROACHING"); else this.FinishOrder(); return true; } // Check if the target is still repairable var 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.OnGlobalConstructionFinished({"entity": this.repairTarget, "newentity": this.repairTarget}); return true; } let cmpBuilderList = QueryBuilderListInterface(this.repairTarget); if (cmpBuilderList) cmpBuilderList.AddBuilder(this.entity); 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(); }, "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; } var 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.MoveToTargetRange(this.repairTarget, IID_Builder)) this.SetNextState("APPROACHING"); else if (!this.CheckTargetRange(this.repairTarget, IID_Builder)) this.FinishOrder(); //can't approach and isn't in reach }, }, "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 var 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). var oldState = this.GetCurrentState(); // Drop any resource we can if we are in range when the construction finishes var cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); var cmpResourceDropsite = Engine.QueryInterface(msg.data.newentity, IID_ResourceDropsite); if (cmpResourceGatherer && cmpResourceDropsite && this.CheckTargetRange(msg.data.newentity, IID_Builder) && this.CanReturnResource(msg.data.newentity, true)) { let dropsiteTypes = cmpResourceDropsite.GetTypes(); cmpResourceGatherer.CommitResources(dropsiteTypes); this.SetDefaultAnimationVariant(); } // We finished building it. // Switch to the next order (if any) if (this.FinishOrder()) { if (this.CanReturnResource(msg.data.newentity, true)) { this.SetDefaultAnimationVariant(); this.PushOrderFront("ReturnResource", { "target": msg.data.newentity, "force": false }); } return; } // 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)) { if (this.CanReturnResource(msg.data.newentity, true)) { this.SetDefaultAnimationVariant(); this.PushOrder("ReturnResource", { "target": msg.data.newentity, "force": false }); } 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)) { var cmpResourceDropsite = Engine.QueryInterface(msg.data.newentity, IID_ResourceDropsite); var 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! var nearby = this.FindNearbyResource(function(ent, type, template) { return (types.indexOf(type.generic) != -1); }, msg.data.newentity); if (nearby) { this.PerformGather(nearby, true, false); return; } } // Look for a nearby foundation to help with var nearbyFoundation = this.FindNearbyFoundation(); 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 === "INDIVIDUAL.REPAIR.APPROACHING") { // We're already walking to the given point, so add this as a order. this.WalkToTarget(msg.data.newentity, true); } }, }, "GARRISON": { "enter": function() { // If the garrisonholder should pickup, warn it so it can take needed action var 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 }); } }, "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; } }, "APPROACHING": { "enter": function() { this.SelectAnimation("move"); }, "MoveCompleted": function() { this.SetNextState("GARRISONED"); }, }, "GARRISONED": { "enter": function() { if (this.order.data.target) var target = this.order.data.target; else { 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; 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 var cmpResourceDropsite = Engine.QueryInterface(target, IID_ResourceDropsite); if (cmpResourceDropsite && this.CanReturnResource(target, true)) { // Dump any resources we can var dropsiteTypes = cmpResourceDropsite.GetTypes(); var cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (cmpResourceGatherer) { cmpResourceGatherer.CommitResources(dropsiteTypes); 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 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)) { this.FinishOrder(); return true; } } if (this.MoveToTarget(target)) { this.SetNextState("APPROACHING"); return false; } } } // Garrisoning failed for some reason, so finish the order this.FinishOrder(); return true; }, "leave": function() { } }, }, "CHEERING": { "enter": function() { // Unit is invulnerable while cheering var cmpDamageReceiver = Engine.QueryInterface(this.entity, IID_DamageReceiver); cmpDamageReceiver.SetInvulnerability(true); this.SelectAnimation("promotion"); this.StartTimer(2800, 2800); return false; }, "leave": function() { this.StopTimer(); var cmpDamageReceiver = Engine.QueryInterface(this.entity, IID_DamageReceiver); cmpDamageReceiver.SetInvulnerability(false); }, "Timer": function(msg) { this.FinishOrder(); }, }, "PACKING": { "enter": function() { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); cmpPack.Pack(); }, "PackFinished": function(msg) { this.FinishOrder(); }, "leave": function() { }, "Attacked": function(msg) { // Ignore attacks while packing }, }, "UNPACKING": { "enter": function() { var cmpPack = Engine.QueryInterface(this.entity, IID_Pack); cmpPack.Unpack(); }, "PackFinished": function(msg) { this.FinishOrder(); }, "leave": function() { }, "Attacked": function(msg) { // Ignore attacks while unpacking }, }, "PICKUP": { "APPROACHING": { "enter": function() { this.SelectAnimation("move"); }, "MoveCompleted": function() { this.SetNextState("LOADING"); }, "PickupCanceled": function() { this.StopMoving(); this.FinishOrder(); }, }, "LOADING": { "enter": function() { this.SelectAnimation("idle"); 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 var range = 4; if (this.MoveToTargetRangeExplicit(msg.data.target, range, range)) { // We've started walking to the given point this.SetNextState("WALKING"); } else { // We are already at the target, or can't move at all this.FinishOrder(); } }, "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.SelectAnimation("walk", false, 1); 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)); }, "leave": function() { this.StopTimer(); this.SetFacePointAfterMove(true); }, "LosRangeUpdate": function(msg) { if (this.template.NaturalBehaviour == "skittish") { if (msg.data.added.length > 0) { this.Flee(msg.data.added[0], false); return; } } // Start attacking one of the newly-seen enemy (if any) else if (this.IsDangerousAnimal()) { 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 LosRangeUpdate // 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"); }, "MoveCompleted": 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)); }, "leave": function() { this.StopTimer(); }, "LosRangeUpdate": function(msg) { if (this.template.NaturalBehaviour == "skittish") { if (msg.data.added.length > 0) { this.Flee(msg.data.added[0], false); return; } } // Start attacking one of the newly-seen enemy (if any) else if (this.template.NaturalBehaviour == "violent") { this.AttackVisibleEntity(msg.data.added); } }, "MoveCompleted": function() { }, "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 }, }; 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.finishedOrder = false; // used to find if all formation members finished the order 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.SetStance(this.template.DefaultStance); }; UnitAI.prototype.IsTurret = function() { if (!this.IsGarrisoned()) return false; var 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.HasFinishedOrder = function() { return this.finishedOrder; }; UnitAI.prototype.ResetFinishOrder = function() { this.finishedOrder = false; }; 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.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, ""); // Clean up range queries var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losRangeQuery) cmpRangeManager.DestroyActiveQuery(this.losRangeQuery); if (this.losHealRangeQuery) cmpRangeManager.DestroyActiveQuery(this.losHealRangeQuery); }; 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 normal and healer range queries. UnitAI.prototype.SetupRangeQueries = function() { this.SetupRangeQuery(); if (this.IsHealer()) this.SetupHealRangeQuery(); }; UnitAI.prototype.UpdateRangeQueries = function() { var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losRangeQuery) this.SetupRangeQuery(cmpRangeManager.IsActiveQueryEnabled(this.losRangeQuery)); if (this.IsHealer() && this.losHealRangeQuery) this.SetupHealRangeQuery(cmpRangeManager.IsActiveQueryEnabled(this.losHealRangeQuery)); }; // Set up a range query for all enemy and gaia units within LOS range // which can be attacked. // This should be called whenever our ownership changes. UnitAI.prototype.SetupRangeQuery = function(enable = true) { var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losRangeQuery) { cmpRangeManager.DestroyActiveQuery(this.losRangeQuery); this.losRangeQuery = undefined; } var cmpPlayer = QueryOwnerInterface(this.entity); // If we are being destructed (owner -1), creating a range query is pointless if (!cmpPlayer) return; // Exclude allies, and self // TODO: How to handle neutral players - Special query to attack military only? var players = cmpPlayer.GetEnemies(); var range = this.GetQueryRange(IID_Attack); this.losRangeQuery = cmpRangeManager.CreateActiveQuery(this.entity, range.min, range.max, players, IID_DamageReceiver, 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. // This should be called whenever our ownership changes. UnitAI.prototype.SetupHealRangeQuery = function(enable = true) { var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (this.losHealRangeQuery) { cmpRangeManager.DestroyActiveQuery(this.losHealRangeQuery); this.losHealRangeQuery = undefined; } var cmpPlayer = QueryOwnerInterface(this.entity); // If we are being destructed (owner -1), creating a range query is pointless if (!cmpPlayer) return; var players = cmpPlayer.GetAllies(); var 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); }; //// 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() || 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, but only if our current state is not IDLE // as this can trigger infinite loops by entering IDLE repeatedly. if (!this.GetCurrentState().endsWith(".IDLE")) this.SetNextState("IDLE"); Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); // Check if there are queued formation orders if (this.IsFormationMember()) { let cmpUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); if (cmpUnitAI) { // Inform the formation controller that we finished this task this.finishedOrder = true; // 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) { 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 (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() }); }; 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(); 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; }; //// Message handlers ///// UnitAI.prototype.OnMotionChanged = function(msg) { if (msg.starting && !msg.error) this.UnitFsm.ProcessMessage(this, {"type": "MoveStarted", "data": msg}); else if (!msg.starting || msg.error) this.UnitFsm.ProcessMessage(this, {"type": "MoveCompleted", "data": msg}); }; UnitAI.prototype.OnGlobalConstructionFinished = function(msg) { // TODO: This is a bit inefficient since every unit listens to every // construction message - ideally we could scope it to only the one we're building this.UnitFsm.ProcessMessage(this, {"type": "ConstructionFinished", "data": msg}); }; UnitAI.prototype.OnGlobalEntityRenamed = function(msg) { let changed = false; for (let order of this.orderQueue) { if (order.data && order.data.target && order.data.target == msg.entity) { changed = true; order.data.target = msg.newentity; } if (order.data && order.data.formationTarget && order.data.formationTarget == msg.entity) { changed = true; order.data.formationTarget = msg.newentity; } } if (changed) Engine.PostMessage(this.entity, MT_UnitAIOrderDataChanged, { "to": this.GetOrderData() }); }; UnitAI.prototype.OnAttacked = function(msg) { 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}); }; 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.GetRunSpeedMultiplier(); + 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 entity ID of the nearest resource supply where the given * filter returns true, or undefined if none can be found. * if target if given, the nearest is computed versus this target position. * TODO: extend this to exclude resources that already have lots of * gatherers. */ UnitAI.prototype.FindNearbyResource = function(filter, target) { var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || cmpOwnership.GetOwner() == INVALID_PLAYER) return undefined; var owner = cmpOwnership.GetOwner(); // We accept resources owned by Gaia or any player var players = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetAllPlayers(); var range = 64; // TODO: what's a sensible number? var cmpTemplateManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_TemplateManager); var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); let entity = this.entity; if (target) { let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) entity = target; } var nearby = cmpRangeManager.ExecuteQuery(entity, 0, range, players, IID_ResourceSupply); return nearby.find(ent => { if (!this.CanGather(ent) || !this.CheckTargetVisible(ent)) return false; var cmpResourceSupply = Engine.QueryInterface(ent, IID_ResourceSupply); var type = cmpResourceSupply.GetType(); var amount = cmpResourceSupply.GetCurrentAmount(); var 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 nearbyDropsites = 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 nearbyDropsites) { // 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, * or undefined if none can be found close enough. */ UnitAI.prototype.FindNearbyFoundation = function() { var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || cmpOwnership.GetOwner() == INVALID_PLAYER) return undefined; // Find buildings owned by this unit's player var players = [cmpOwnership.GetOwner()]; var range = 64; // TODO: what's a sensible number? var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); var nearby = cmpRangeManager.ExecuteQuery(this.entity, 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); return; }; /* * 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) { this.SetAnimationVariant(""); return; } 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.SelectAnimation = function(name, once = false, speed = 1.0) { let cmpVisual = Engine.QueryInterface(this.entity, IID_Visual); if (!cmpVisual) return; // Special case: the "move" animation gets turned into a special // movement mode that deals with speeds and walk/run automatically if (name == "move") { // Speed to switch from walking to running animations cmpVisual.SelectMovementAnimation(this.GetWalkSpeed()); 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() { var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); cmpUnitMotion.StopMoving(); }; UnitAI.prototype.MoveToPoint = function(x, z) { var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.MoveToPointRange(x, z, 0, 0); }; UnitAI.prototype.MoveToPointRange = function(x, z, rangeMin, rangeMax) { var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.MoveToPointRange(x, z, rangeMin, rangeMax); }; UnitAI.prototype.MoveToTarget = function(target) { if (!this.CheckTargetVisible(target)) return false; var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.MoveToTargetRange(target, 0, 0); }; UnitAI.prototype.MoveToTargetRange = function(target, iid, type) { if (!this.CheckTargetVisible(target) || this.IsTurret()) return false; var cmpRanged = Engine.QueryInterface(this.entity, iid); if (!cmpRanged) return false; var range = cmpRanged.GetRange(type); var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return 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()) { var cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); if (cmpFormationUnitAI && cmpFormationUnitAI.IsAttackingAsFormation()) return false; } var 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; var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); var range = cmpAttack.GetRange(type); var thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!thisCmpPosition.IsInWorld()) return false; var s = thisCmpPosition.GetPosition(); var targetCmpPosition = Engine.QueryInterface(target, IID_Position); if (!targetCmpPosition.IsInWorld()) return false; var t = targetCmpPosition.GetPosition(); // h is positive when I'm higher than the target var h = s.y-t.y+range.elevationBonus; // No negative roots please if (h>-range.max/2) var parabolicMaxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h); else // return false? Or hope you come close enough? var parabolicMaxRange = 0; //return false; // the parabole changes while walking, take something in the middle var guessedMaxRange = (range.max + parabolicMaxRange)/2; var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion.MoveToTargetRange(target, range.min, guessedMaxRange)) return true; // if that failed, try closer return cmpUnitMotion.MoveToTargetRange(target, range.min, Math.min(range.max, parabolicMaxRange)); }; UnitAI.prototype.MoveToTargetRangeExplicit = function(target, min, max) { if (!this.CheckTargetVisible(target)) return false; var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.MoveToTargetRange(target, min, 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(); var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.MoveToTargetRange(target, range.min, range.max); }; UnitAI.prototype.CheckPointRangeExplicit = function(x, z, min, max) { var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.IsInPointRange(x, z, min, max); }; UnitAI.prototype.CheckTargetRange = function(target, iid, type) { var cmpRanged = Engine.QueryInterface(this.entity, iid); if (!cmpRanged) return false; var range = cmpRanged.GetRange(type); var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.IsInTargetRange(target, range.min, range.max); }; /** * 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()) { var cmpFormationUnitAI = Engine.QueryInterface(this.formationController, IID_UnitAI); if (cmpFormationUnitAI && cmpFormationUnitAI.IsAttackingAsFormation() && cmpFormationUnitAI.order.data.target == target) return true; } var cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) target = cmpFormation.GetClosestMember(this.entity); if (type != "Ranged") return this.CheckTargetRange(target, IID_Attack, type); var targetCmpPosition = Engine.QueryInterface(target, IID_Position); if (!targetCmpPosition || !targetCmpPosition.IsInWorld()) return false; var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); var range = cmpAttack.GetRange(type); var thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!thisCmpPosition.IsInWorld()) return false; var s = thisCmpPosition.GetPosition(); var t = targetCmpPosition.GetPosition(); var h = s.y-t.y+range.elevationBonus; var maxRangeSq = 2*range.max*(h + range.max/2); if (maxRangeSq < 0) return false; var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.IsInTargetRange(target, range.min, Math.sqrt(maxRangeSq)); }; UnitAI.prototype.CheckTargetRangeExplicit = function(target, min, max) { var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.IsInTargetRange(target, min, max); }; UnitAI.prototype.CheckGarrisonRange = function(target) { var 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) var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); return cmpUnitMotion.IsInTargetRange(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; }; UnitAI.prototype.FaceTowardsTarget = function(target) { var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return; var cmpTargetPosition = Engine.QueryInterface(target, IID_Position); if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) return; var targetpos = cmpTargetPosition.GetPosition2D(); var angle = cmpPosition.GetPosition2D().angleTo(targetpos); var rot = cmpPosition.GetRotation(); var delta = (rot.y - angle + Math.PI) % (2 * Math.PI) - Math.PI; if (Math.abs(delta) > 0.2) { var cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion) cmpUnitMotion.FaceTowardsPoint(targetpos.x, targetpos.y); } }; UnitAI.prototype.CheckTargetDistanceFromHeldPosition = function(target, iid, type) { var cmpRanged = Engine.QueryInterface(this.entity, iid); var range = iid !== IID_Attack ? cmpRanged.GetRange() : cmpRanged.GetRange(type); var cmpPosition = Engine.QueryInterface(target, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return false; var cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) return false; var halfvision = cmpVision.GetRange() / 2; var pos = cmpPosition.GetPosition(); var 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) { var cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) return false; var range = cmpVision.GetRange(); var 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) { this.PushOrderFront("Flee", { "target": ents[0], "force": false }); return true; } return false; }; /** * Try to respond to healable entities. * Returns true if it responded. */ UnitAI.prototype.RespondToHealableEntities = function(ents) { var 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.IsTurret()) 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 // Ignore also the request if we are packing if (this.order && (this.order.type == "LeaveFoundation" || (this.order.type == "Flee" && this.order.data.target == target) || 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; } this.AddOrder("Attack", { "target": target, "force": true, "allowCapture": allowCapture}, 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.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); // Remember the position of our target, if any, in case it disappears // later and we want to head to its last known position var lastPos = undefined; var cmpPosition = Engine.QueryInterface(target, IID_Position); if (cmpPosition && cmpPosition.IsInWorld()) lastPos = cmpPosition.GetPosition(); this.AddOrder("Gather", { "target": target, "type": type, "template": template, "lastPos": lastPos, "force": force }, 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); }; /** * 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.MoveToTarget(targetMarket); }; 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); }; /** * Adds cheer order to the queue. Forced so it won't be interrupted by attacks. */ UnitAI.prototype.Cheer = function() { this.AddOrder("Cheering", { "force": true }, false); }; 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 losRangeQuery, 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.losRangeQuery) return false; if (!this.GetStance().targetVisibleEnemies) return false; var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); return this.AttackEntitiesByPreference(cmpRangeManager.ResetActiveQuery(this.losRangeQuery)); }; 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.losRangeQuery) return []; if (!this.GetStance().targetVisibleEnemies) return []; var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return []; 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()); }; var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); var entities = cmpRangeManager.ResetActiveQuery(this.losRangeQuery); var targets = entities.filter(function(v) { return cmpAttack.CanAttack(v) && attackfilter(v); }) .sort(function(a, b) { return cmpAttack.CompareEntitiesByPreference(a, b); }); return targets; }; /** * 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; var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); return this.RespondToHealableEntities(cmpRangeManager.ResetActiveQuery(this.losHealRangeQuery)); }; UnitAI.prototype.GetQueryRange = function(iid) { var ret = { "min": 0, "max": 0 }; if (this.GetStance().respondStandGround) { var cmpRanged = Engine.QueryInterface(this.entity, iid); if (!cmpRanged) return ret; var range = iid !== IID_Attack ? cmpRanged.GetRange() : cmpRanged.GetFullAttackRange(); var cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) return ret; ret.min = range.min; ret.max = Math.min(range.max, cmpVision.GetRange()); } else if (this.GetStance().respondChase) { var cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) return ret; var range = cmpVision.GetRange(); ret.max = range; } else if (this.GetStance().respondHoldGround) { var cmpRanged = Engine.QueryInterface(this.entity, iid); if (!cmpRanged) return ret; var range = iid !== IID_Attack ? cmpRanged.GetRange() : cmpRanged.GetFullAttackRange(); var cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) return ret; var vision = cmpVision.GetRange(); ret.max = Math.min(range.max + vision / 2, vision); } // 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) { var cmpVision = Engine.QueryInterface(this.entity, IID_Vision); if (!cmpVision) return ret; var range = cmpVision.GetRange(); ret.max = range; } 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.ResetMoveSpeed = function() +UnitAI.prototype.ResetSpeedMultiplier = function() { let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion) - cmpUnitMotion.SetSpeedRatio(1); + cmpUnitMotion.SetSpeedMultiplier(1); }; -UnitAI.prototype.SetMoveSpeedRatio = function(speed) +UnitAI.prototype.SetSpeedMultiplier = function(speed) { let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpUnitMotion) - cmpUnitMotion.SetSpeedRatio(speed); + cmpUnitMotion.SetSpeedMultiplier(speed); }; 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 //// 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; var 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 var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || !(IsOwnedByPlayer(cmpOwnership.GetOwner(), target) || IsOwnedByMutualAllyOfPlayer(cmpOwnership.GetOwner(), target))) return false; // Don't let animals garrison for now // (If we want to support that, we'll need to change Order.Garrison so it // doesn't move the animal into an INVIDIDUAL.* state) if (this.IsAnimal()) 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; // Verify that we're able to respond to Heal commands var cmpHeal = Engine.QueryInterface(this.entity, IID_Heal); if (!cmpHeal) return false; // Verify that the target is alive if (!this.TargetIsAlive(target)) return false; // Verify that the target is owned by the same player as the entity or of an ally var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (!cmpOwnership || !(IsOwnedByPlayer(cmpOwnership.GetOwner(), target) || IsOwnedByAllyOfPlayer(cmpOwnership.GetOwner(), target))) return false; // Verify that the target is not unhealable (or at max health) var cmpHealth = Engine.QueryInterface(target, IID_Health); if (!cmpHealth || cmpHealth.IsUnhealable()) return false; // Verify that the target has no unhealable class var cmpIdentity = Engine.QueryInterface(target, IID_Identity); if (!cmpIdentity) return false; if (MatchesClassList(cmpIdentity.GetClassesList(), cmpHeal.GetUnhealableClasses())) return false; // Verify that the target is a healable class if (MatchesClassList(cmpIdentity.GetClassesList(), cmpHeal.GetHealableClasses())) return true; return false; }; UnitAI.prototype.CanReturnResource = function(target, checkCarriedResource) { 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 var cmpResourceGatherer = Engine.QueryInterface(this.entity, IID_ResourceGatherer); if (!cmpResourceGatherer) return false; // Verify that the target is a dropsite var 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 var 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) var cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); if (cmpOwnership && IsOwnedByPlayer(cmpOwnership.GetOwner(), target)) return true; var 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, dist); }; UnitAI.prototype.SetFacePointAfterMove = function(val) { var cmpMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); if (cmpMotion) cmpMotion.SetFacePointAfterMove(val); }; 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 obj.funcname(args) on UnitAI components of all formation members. */ UnitAI.prototype.CallMemberFunction = function(funcname, args) { var cmpFormation = Engine.QueryInterface(this.entity, IID_Formation); if (!cmpFormation) return; cmpFormation.GetMembers().forEach(ent => { var 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/binaries/data/mods/public/simulation/components/UnitMotionFlying.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/UnitMotionFlying.js (revision 22276) +++ ps/trunk/binaries/data/mods/public/simulation/components/UnitMotionFlying.js (revision 22277) @@ -1,359 +1,359 @@ // (A serious implementation of this might want to use C++ instead of JS // for performance; this is just for fun.) const SHORT_FINAL = 2.5; function UnitMotionFlying() {} UnitMotionFlying.prototype.Schema = "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; UnitMotionFlying.prototype.Init = function() { this.hasTarget = false; this.reachedTarget = false; this.targetX = 0; this.targetZ = 0; this.targetMinRange = 0; this.targetMaxRange = 0; this.speed = 0; this.landing = false; this.onGround = true; this.pitch = 0; this.roll = 0; this.waterDeath = false; this.passabilityClass = Engine.QueryInterface(SYSTEM_ENTITY, IID_Pathfinder).GetPassabilityClass(this.template.PassabilityClass); }; UnitMotionFlying.prototype.OnUpdate = function(msg) { var turnLength = msg.turnLength; if (!this.hasTarget) return; var cmpGarrisonHolder = Engine.QueryInterface(this.entity, IID_GarrisonHolder); var cmpHealth = Engine.QueryInterface(this.entity, IID_Health); var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); var pos = cmpPosition.GetPosition(); var angle = cmpPosition.GetRotation().y; var cmpTerrain = Engine.QueryInterface(SYSTEM_ENTITY, IID_Terrain); var cmpWaterManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_WaterManager); var ground = Math.max(cmpTerrain.GetGroundLevel(pos.x, pos.z), cmpWaterManager.GetWaterLevel(pos.x, pos.z)); var newangle = angle; var canTurn = true; if (this.landing) { if (this.speed > 0 && this.onGround) { if (pos.y <= cmpWaterManager.GetWaterLevel(pos.x, pos.z) && this.template.DiesInWater == "true") this.waterDeath = true; this.pitch = 0; // Deaccelerate forwards...at a very reduced pace. if (this.waterDeath) this.speed = Math.max(0, this.speed - turnLength * this.template.BrakingRate * 10); else this.speed = Math.max(0, this.speed - turnLength * this.template.BrakingRate); canTurn = false; // Clamp to ground if below it, or descend if above if (pos.y < ground) pos.y = ground; else if (pos.y > ground) pos.y = Math.max(ground, pos.y - turnLength * this.template.ClimbRate); } else if (this.speed == 0 && this.onGround) { if (this.waterDeath && cmpHealth) cmpHealth.Kill(); else { this.pitch = 0; // We've stopped. if (cmpGarrisonHolder) cmpGarrisonHolder.AllowGarrisoning(true,"UnitMotionFlying"); canTurn = false; this.hasTarget = false; this.landing = false; // summon planes back from the edge of the map var terrainSize = cmpTerrain.GetMapSize(); var cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (cmpRangeManager.GetLosCircular()) { var mapRadius = terrainSize/2; var x = pos.x - mapRadius; var z = pos.z - mapRadius; var div = (mapRadius - 12) / Math.sqrt(x*x + z*z); if (div < 1) { pos.x = mapRadius + x*div; pos.z = mapRadius + z*div; newangle += Math.PI; } } else { pos.x = Math.max(Math.min(pos.x, terrainSize - 12), 12); pos.z = Math.max(Math.min(pos.z, terrainSize - 12), 12); newangle += Math.PI; } } } else { // Final Approach // We need to slow down to land! this.speed = Math.max(this.template.LandingSpeed, this.speed - turnLength * this.template.SlowingRate); canTurn = false; var targetHeight = ground; // Steep, then gradual descent. if ((pos.y - targetHeight) / this.template.FlyingHeight > 1 / SHORT_FINAL) this.pitch = - Math.PI / 18; else this.pitch = Math.PI / 18; var descentRate = ((pos.y - targetHeight) / this.template.FlyingHeight * this.template.ClimbRate + SHORT_FINAL) * SHORT_FINAL; if (pos.y < targetHeight) pos.y = Math.max(targetHeight, pos.y + turnLength * descentRate); else if (pos.y > targetHeight) pos.y = Math.max(targetHeight, pos.y - turnLength * descentRate); if (targetHeight == pos.y) { this.onGround = true; if (targetHeight == cmpWaterManager.GetWaterLevel(pos.x, pos.z) && this.template.DiesInWater) this.waterDeath = true; } } } else { // If we haven't reached max speed yet then we're still on the ground; // otherwise we're taking off or flying // this.onGround in case of a go-around after landing (but not fully stopped) if (this.speed < this.template.TakeoffSpeed && this.onGround) { if (cmpGarrisonHolder) cmpGarrisonHolder.AllowGarrisoning(false,"UnitMotionFlying"); this.pitch = 0; // Accelerate forwards this.speed = Math.min(this.template.MaxSpeed, this.speed + turnLength * this.template.AccelRate); canTurn = false; // Clamp to ground if below it, or descend if above if (pos.y < ground) pos.y = ground; else if (pos.y > ground) pos.y = Math.max(ground, pos.y - turnLength * this.template.ClimbRate); } else { this.onGround = false; // Climb/sink to max height above ground this.speed = Math.min(this.template.MaxSpeed, this.speed + turnLength * this.template.AccelRate); var targetHeight = ground + (+this.template.FlyingHeight); if (Math.abs(pos.y-targetHeight) > this.template.FlyingHeight/5) { this.pitch = Math.PI / 9; canTurn = false; } else this.pitch = 0; if (pos.y < targetHeight) pos.y = Math.min(targetHeight, pos.y + turnLength * this.template.ClimbRate); else if (pos.y > targetHeight) { pos.y = Math.max(targetHeight, pos.y - turnLength * this.template.ClimbRate); this.pitch = -1 * this.pitch; } } } // If we're in range of the target then tell people that we've reached it // (TODO: quantisation breaks this) var distFromTarget = Math.euclidDistance2D(pos.x, pos.z, this.targetX, this.targetZ); if (!this.reachedTarget && this.targetMinRange <= distFromTarget && distFromTarget <= this.targetMaxRange) { this.reachedTarget = true; Engine.PostMessage(this.entity, MT_MotionChanged, { "starting": false, "error": false }); } // If we're facing away from the target, and are still fairly close to it, // then carry on going straight so we overshoot in a straight line var isBehindTarget = ((this.targetX - pos.x) * Math.sin(angle) + (this.targetZ - pos.z) * Math.cos(angle) < 0); // Overshoot the target: carry on straight if (isBehindTarget && distFromTarget < this.template.MaxSpeed * this.template.OvershootTime) canTurn = false; if (canTurn) { // Turn towards the target var targetAngle = Math.atan2(this.targetX - pos.x, this.targetZ - pos.z); var delta = targetAngle - angle; // Wrap delta to -pi..pi delta = (delta + Math.PI) % (2*Math.PI); // range -2pi..2pi if (delta < 0) delta += 2*Math.PI; // range 0..2pi delta -= Math.PI; // range -pi..pi // Clamp to max rate var deltaClamped = Math.min(Math.max(delta, -this.template.TurnRate * turnLength), this.template.TurnRate * turnLength); // Calculate new orientation, in a peculiar way in order to make sure the // result gets close to targetAngle (rather than being n*2*pi out) newangle = targetAngle + deltaClamped - delta; if (newangle - angle > Math.PI / 18) this.roll = Math.PI / 9; else if (newangle - angle < -Math.PI / 18) this.roll = - Math.PI / 9; else this.roll = newangle - angle; } else this.roll = 0; pos.x += this.speed * turnLength * Math.sin(angle); pos.z += this.speed * turnLength * Math.cos(angle); cmpPosition.SetHeightFixed(pos.y); cmpPosition.TurnTo(newangle); cmpPosition.SetXZRotation(this.pitch, this.roll); cmpPosition.MoveTo(pos.x, pos.z); }; UnitMotionFlying.prototype.MoveToPointRange = function(x, z, minRange, maxRange) { this.hasTarget = true; this.landing = false; this.reachedTarget = false; this.targetX = x; this.targetZ = z; this.targetMinRange = minRange; this.targetMaxRange = maxRange; return true; }; UnitMotionFlying.prototype.MoveToTargetRange = function(target, minRange, maxRange) { var cmpTargetPosition = Engine.QueryInterface(target, IID_Position); if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) return false; var targetPos = cmpTargetPosition.GetPosition2D(); this.hasTarget = true; this.reachedTarget = false; this.targetX = targetPos.x; this.targetZ = targetPos.y; this.targetMinRange = minRange; this.targetMaxRange = maxRange; return true; }; UnitMotionFlying.prototype.IsInPointRange = function(x, y, minRange, maxRange) { var cmpPosition = Engine.QueryInterface(this.entity, IID_Position); var pos = cmpPosition.GetPosition2D(); var distFromTarget = Math.euclidDistance2D(x, y, pos.x, pos.y); if (minRange <= distFromTarget && distFromTarget <= maxRange) return true; return false; }; UnitMotionFlying.prototype.IsInTargetRange = function(target, minRange, maxRange) { var cmpTargetPosition = Engine.QueryInterface(target, IID_Position); if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) return false; var targetPos = cmpTargetPosition.GetPosition2D(); return this.IsInPointRange(targetPos.x, targetPos.y, minRange, maxRange); }; UnitMotionFlying.prototype.GetWalkSpeed = function() { return +this.template.MaxSpeed; }; -UnitMotionFlying.prototype.SetSpeedRatio = function() +UnitMotionFlying.prototype.SetSpeedMultiplier = function() { // ignore this, the speed is always the walk speed }; -UnitMotionFlying.prototype.GetRunSpeedMultiplier = function() +UnitMotionFlying.prototype.GetRunMultiplier = function() { return 1; }; UnitMotionFlying.prototype.GetCurrentSpeed = function() { return this.speed; }; -UnitMotionFlying.prototype.GetSpeedRatio = function() +UnitMotionFlying.prototype.GetSpeedMultiplier = function() { return this.GetCurrentSpeed() / this.GetWalkSpeed(); } UnitMotionFlying.prototype.GetPassabilityClassName = function() { return this.template.PassabilityClass; }; UnitMotionFlying.prototype.GetPassabilityClass = function() { return this.passabilityClass; }; UnitMotionFlying.prototype.FaceTowardsPoint = function(x, z) { // Ignore this - angle is controlled by the target-seeking code instead }; UnitMotionFlying.prototype.SetFacePointAfterMove = function() { // Ignore this - angle is controlled by the target-seeking code instead }; UnitMotionFlying.prototype.StopMoving = function() { //Invert if (!this.waterDeath) this.landing = !this.landing; }; UnitMotionFlying.prototype.SetDebugOverlay = function(enabled) { }; Engine.RegisterComponentType(IID_UnitMotion, "UnitMotionFlying", UnitMotionFlying); Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_UnitAI.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_UnitAI.js (revision 22276) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_UnitAI.js (revision 22277) @@ -1,322 +1,322 @@ Engine.LoadHelperScript("FSM.js"); Engine.LoadHelperScript("Entity.js"); Engine.LoadHelperScript("Player.js"); Engine.LoadComponentScript("interfaces/Attack.js"); Engine.LoadComponentScript("interfaces/Auras.js"); Engine.LoadComponentScript("interfaces/BuildingAI.js"); Engine.LoadComponentScript("interfaces/Capturable.js"); Engine.LoadComponentScript("interfaces/DamageReceiver.js"); Engine.LoadComponentScript("interfaces/Formation.js"); Engine.LoadComponentScript("interfaces/Heal.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/Pack.js"); Engine.LoadComponentScript("interfaces/ResourceSupply.js"); Engine.LoadComponentScript("interfaces/Timer.js"); Engine.LoadComponentScript("interfaces/UnitAI.js"); Engine.LoadComponentScript("Formation.js"); Engine.LoadComponentScript("UnitAI.js"); /* Regression test. * Tests the FSM behaviour of a unit when walking as part of a formation, * then exiting the formation. * mode == 0: There is no enemy unit nearby. * mode == 1: There is a live enemy unit nearby. * mode == 2: There is a dead enemy unit nearby. */ function TestFormationExiting(mode) { ResetState(); var playerEntity = 5; var unit = 10; var enemy = 20; var controller = 30; AddMock(SYSTEM_ENTITY, IID_Timer, { SetInterval: function() { }, SetTimeout: function() { }, }); AddMock(SYSTEM_ENTITY, IID_RangeManager, { CreateActiveQuery: function(ent, minRange, maxRange, players, iid, flags) { return 1; }, EnableActiveQuery: function(id) { }, ResetActiveQuery: function(id) { if (mode == 0) return []; else return [enemy]; }, DisableActiveQuery: function(id) { }, GetEntityFlagMask: function(identifier) { }, }); AddMock(SYSTEM_ENTITY, IID_TemplateManager, { GetCurrentTemplateName: function(ent) { return "special/formations/line_closed"; }, }); AddMock(SYSTEM_ENTITY, IID_PlayerManager, { GetPlayerByID: function(id) { return playerEntity; }, GetNumPlayers: function() { return 2; }, }); AddMock(playerEntity, IID_Player, { IsAlly: function() { return false; }, IsEnemy: function() { return true; }, GetEnemies: function() { return []; }, }); var unitAI = ConstructComponent(unit, "UnitAI", { "FormationController": "false", "DefaultStance": "aggressive" }); AddMock(unit, IID_Identity, { GetClassesList: function() { return []; }, }); AddMock(unit, IID_Ownership, { GetOwner: function() { return 1; }, }); AddMock(unit, IID_Position, { GetTurretParent: function() { return INVALID_ENTITY; }, GetPosition: function() { return new Vector3D(); }, GetPosition2D: function() { return new Vector2D(); }, GetRotation: function() { return { "y": 0 }; }, IsInWorld: function() { return true; }, }); AddMock(unit, IID_UnitMotion, { GetWalkSpeed: function() { return 1; }, MoveToFormationOffset: function(target, x, z) { }, IsInTargetRange: function(target, min, max) { return true; }, MoveToTargetRange: function(target, min, max) { }, StopMoving: function() { }, GetPassabilityClassName: function() { return "default"; }, }); AddMock(unit, IID_Vision, { GetRange: function() { return 10; }, }); AddMock(unit, IID_Attack, { GetRange: function() { return { "max": 10, "min": 0}; }, GetFullAttackRange: function() { return { "max": 40, "min": 0}; }, GetBestAttackAgainst: function(t) { return "melee"; }, GetPreference: function(t) { return 0; }, GetTimers: function() { return { "prepare": 500, "repeat": 1000 }; }, CanAttack: function(v) { return true; }, CompareEntitiesByPreference: function(a, b) { return 0; }, }); unitAI.OnCreate(); unitAI.SetupRangeQuery(1); if (mode == 1) { AddMock(enemy, IID_Health, { GetHitpoints: function() { return 10; }, }); AddMock(enemy, IID_UnitAI, { IsAnimal: function() { return false; } }); } else if (mode == 2) AddMock(enemy, IID_Health, { GetHitpoints: function() { return 0; }, }); var controllerFormation = ConstructComponent(controller, "Formation", {"FormationName": "Line Closed", "FormationShape": "square", "ShiftRows": "false", "SortingClasses": "", "WidthDepthRatio": 1, "UnitSeparationWidthMultiplier": 1, "UnitSeparationDepthMultiplier": 1, "SpeedMultiplier": 1, "Sloppyness": 0}); var controllerAI = ConstructComponent(controller, "UnitAI", { "FormationController": "true", "DefaultStance": "aggressive" }); AddMock(controller, IID_Position, { JumpTo: function(x, z) { this.x = x; this.z = z; }, GetTurretParent: function() { return INVALID_ENTITY; }, GetPosition: function() { return new Vector3D(this.x, 0, this.z); }, GetPosition2D: function() { return new Vector2D(this.x, this.z); }, GetRotation: function() { return { "y": 0 }; }, IsInWorld: function() { return true; }, }); AddMock(controller, IID_UnitMotion, { GetWalkSpeed: function() { return 1; }, - SetSpeedRatio: function(speed) { }, + SetSpeedMultiplier: function(speed) { }, MoveToPointRange: function(x, z, minRange, maxRange) { }, GetPassabilityClassName: function() { return "default"; }, }); controllerAI.OnCreate(); TS_ASSERT_EQUALS(controllerAI.fsmStateName, "FORMATIONCONTROLLER.IDLE"); TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.IDLE"); controllerFormation.SetMembers([unit]); controllerAI.Walk(100, 100, false); controllerAI.OnMotionChanged({ "starting": true }); TS_ASSERT_EQUALS(controllerAI.fsmStateName, "FORMATIONCONTROLLER.WALKING"); TS_ASSERT_EQUALS(unitAI.fsmStateName, "FORMATIONMEMBER.WALKING"); controllerFormation.Disband(); if (mode == 0) TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.IDLE"); else if (mode == 1) TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.COMBAT.ATTACKING"); else if (mode == 2) TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.IDLE"); else TS_FAIL("invalid mode"); } function TestMoveIntoFormationWhileAttacking() { ResetState(); var playerEntity = 5; var controller = 10; var enemy = 20; var unit = 30; var units = []; var unitCount = 8; var unitAIs = []; AddMock(SYSTEM_ENTITY, IID_Timer, { SetInterval: function() { }, SetTimeout: function() { }, }); AddMock(SYSTEM_ENTITY, IID_RangeManager, { CreateActiveQuery: function(ent, minRange, maxRange, players, iid, flags) { return 1; }, EnableActiveQuery: function(id) { }, ResetActiveQuery: function(id) { return [enemy]; }, DisableActiveQuery: function(id) { }, GetEntityFlagMask: function(identifier) { }, }); AddMock(SYSTEM_ENTITY, IID_TemplateManager, { GetCurrentTemplateName: function(ent) { return "special/formations/line_closed"; }, }); AddMock(SYSTEM_ENTITY, IID_PlayerManager, { GetPlayerByID: function(id) { return playerEntity; }, GetNumPlayers: function() { return 2; }, }); AddMock(playerEntity, IID_Player, { IsAlly: function() { return false; }, IsEnemy: function() { return true; }, GetEnemies: function() { return []; }, }); // create units for (var i = 0; i < unitCount; i++) { units.push(unit + i); var unitAI = ConstructComponent(unit + i, "UnitAI", { "FormationController": "false", "DefaultStance": "aggressive" }); AddMock(unit + i, IID_Identity, { GetClassesList: function() { return []; }, }); AddMock(unit + i, IID_Ownership, { GetOwner: function() { return 1; }, }); AddMock(unit + i, IID_Position, { GetTurretParent: function() { return INVALID_ENTITY; }, GetPosition: function() { return new Vector3D(); }, GetPosition2D: function() { return new Vector2D(); }, GetRotation: function() { return { "y": 0 }; }, IsInWorld: function() { return true; }, }); AddMock(unit + i, IID_UnitMotion, { GetWalkSpeed: function() { return 1; }, MoveToFormationOffset: function(target, x, z) { }, IsInTargetRange: function(target, min, max) { return true; }, MoveToTargetRange: function(target, min, max) { }, StopMoving: function() { }, GetPassabilityClassName: function() { return "default"; }, }); AddMock(unit + i, IID_Vision, { GetRange: function() { return 10; }, }); AddMock(unit + i, IID_Attack, { GetRange: function() { return {"max":10, "min": 0}; }, GetFullAttackRange: function() { return { "max": 40, "min": 0}; }, GetBestAttackAgainst: function(t) { return "melee"; }, GetTimers: function() { return { "prepare": 500, "repeat": 1000 }; }, CanAttack: function(v) { return true; }, CompareEntitiesByPreference: function(a, b) { return 0; }, }); unitAI.OnCreate(); unitAI.SetupRangeQuery(1); unitAIs.push(unitAI); } // create enemy AddMock(enemy, IID_Health, { GetHitpoints: function() { return 40; }, }); var controllerFormation = ConstructComponent(controller, "Formation", {"FormationName": "Line Closed", "FormationShape": "square", "ShiftRows": "false", "SortingClasses": "", "WidthDepthRatio": 1, "UnitSeparationWidthMultiplier": 1, "UnitSeparationDepthMultiplier": 1, "SpeedMultiplier": 1, "Sloppyness": 0}); var controllerAI = ConstructComponent(controller, "UnitAI", { "FormationController": "true", "DefaultStance": "aggressive" }); AddMock(controller, IID_Position, { GetTurretParent: function() { return INVALID_ENTITY; }, JumpTo: function(x, z) { this.x = x; this.z = z; }, GetPosition: function() { return new Vector3D(this.x, 0, this.z); }, GetPosition2D: function() { return new Vector2D(this.x, this.z); }, GetRotation: function() { return { "y": 0 }; }, IsInWorld: function() { return true; }, }); AddMock(controller, IID_UnitMotion, { GetWalkSpeed: function() { return 1; }, - SetSpeedRatio: function(speed) { }, + SetSpeedMultiplier: function(speed) { }, MoveToPointRange: function(x, z, minRange, maxRange) { }, IsInTargetRange: function(target, min, max) { return true; }, StopMoving: function() { }, GetPassabilityClassName: function() { return "default"; }, }); AddMock(controller, IID_Attack, { GetRange: function() { return {"max":10, "min": 0}; }, CanAttackAsFormation: function() { return false; }, }); controllerAI.OnCreate(); controllerFormation.SetMembers(units); controllerAI.Attack(enemy, []); for (var ent of unitAIs) TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.COMBAT.ATTACKING"); controllerAI.MoveIntoFormation({"name": "Circle"}); // let all units be in position for (var ent of unitAIs) controllerFormation.SetInPosition(ent); for (var ent of unitAIs) TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.COMBAT.ATTACKING"); controllerFormation.Disband(); } TestFormationExiting(0); TestFormationExiting(1); TestFormationExiting(2); TestMoveIntoFormationWhileAttacking(); Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_UnitMotionFlying.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_UnitMotionFlying.js (revision 22276) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_UnitMotionFlying.js (revision 22277) @@ -1,147 +1,147 @@ Engine.LoadComponentScript("UnitMotionFlying.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/GarrisonHolder.js"); let entity = 1; let target = 2; let height = 5; AddMock(SYSTEM_ENTITY, IID_Pathfinder, { GetPassabilityClass: (name) => 1 << 8 }); let cmpUnitMotionFlying = ConstructComponent(entity, "UnitMotionFlying", { "MaxSpeed": 1.0, "TakeoffSpeed": 0.5, "LandingSpeed": 0.5, "AccelRate": 0.0005, "SlowingRate": 0.001, "BrakingRate": 0.0005, "TurnRate": 0.1, "OvershootTime": 10, "FlyingHeight": 100, "ClimbRate": 0.1, "DiesInWater": false, "PassabilityClass": "unrestricted" }); -TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetSpeedRatio(), 0); -TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetRunSpeedMultiplier(), 1); +TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetSpeedMultiplier(), 0); +TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetRunMultiplier(), 1); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 0); -cmpUnitMotionFlying.SetSpeedRatio(2); -TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetSpeedRatio(), 0); -TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetRunSpeedMultiplier(), 1); +cmpUnitMotionFlying.SetSpeedMultiplier(2); +TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetSpeedMultiplier(), 0); +TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetRunMultiplier(), 1); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 0); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetPassabilityClassName(), "unrestricted"); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetPassabilityClass(), 1 << 8); AddMock(entity, IID_Position, { "IsInWorld": () => true, "GetPosition2D": () => { return { "x": 50, "y": 100 }; }, "GetPosition": () => { return { "x": 50, "y": height, "z": 100 }; }, "GetRotation": () => { return { "y": 3.14 }; }, "SetHeightFixed": (y) => height = y, "TurnTo": () => {}, "SetXZRotation": () => {}, "MoveTo": () => {} }); AddMock(target, IID_Position, { "IsInWorld": () => true, "GetPosition2D": () => { return { "x": 100, "y": 200 }; } }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.IsInTargetRange(target, 10, 112), true); TS_ASSERT_EQUALS(cmpUnitMotionFlying.IsInTargetRange(target, 50, 111), false); TS_ASSERT_EQUALS(cmpUnitMotionFlying.IsInTargetRange(target, 112, 200), false); AddMock(entity, IID_GarrisonHolder, { "AllowGarrisoning": () => {} }); AddMock(entity, IID_Health, { }); AddMock(entity, IID_RangeManager, { "GetLosCircular": () => true }); AddMock(entity, IID_Terrain, { "GetGroundLevel": () => 4, "GetMapSize": () => 20 }); AddMock(entity, IID_WaterManager, { "GetWaterLevel": () => 5 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 0); cmpUnitMotionFlying.OnUpdate({ "turnLength": 500 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 0); -TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetSpeedRatio(), 0); +TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetSpeedMultiplier(), 0); TS_ASSERT_EQUALS(cmpUnitMotionFlying.MoveToTargetRange(target, 0, 10), true); TS_ASSERT_EQUALS(cmpUnitMotionFlying.MoveToPointRange(100, 200, 0, 20), true); // Take Off cmpUnitMotionFlying.OnUpdate({ "turnLength": 500 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 0.25); TS_ASSERT_EQUALS(height, 5); cmpUnitMotionFlying.OnUpdate({ "turnLength": 500 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 0.5); TS_ASSERT_EQUALS(height, 5); cmpUnitMotionFlying.OnUpdate({ "turnLength": 0 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 0.5); TS_ASSERT_EQUALS(height, 5); cmpUnitMotionFlying.OnUpdate({ "turnLength": 500 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 0.75); TS_ASSERT_EQUALS(height, 55); cmpUnitMotionFlying.OnUpdate({ "turnLength": 500 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 1); -TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetSpeedRatio(), 1); +TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetSpeedMultiplier(), 1); TS_ASSERT_EQUALS(height, 105); // Fly cmpUnitMotionFlying.OnUpdate({ "turnLength": 100 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 1); TS_ASSERT_EQUALS(height, 105); cmpUnitMotionFlying.OnUpdate({ "turnLength": 500 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 1); TS_ASSERT_EQUALS(height, 105); cmpUnitMotionFlying.OnUpdate({ "turnLength": 0 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 1); TS_ASSERT_EQUALS(height, 105); // Land cmpUnitMotionFlying.StopMoving(); cmpUnitMotionFlying.OnUpdate({ "turnLength": 0 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 1); TS_ASSERT_EQUALS(height, 105); cmpUnitMotionFlying.OnUpdate({ "turnLength": 500 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 0.5); TS_ASSERT_EQUALS(height, 5); // Slide cmpUnitMotionFlying.OnUpdate({ "turnLength": 500 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 0.25); TS_ASSERT_EQUALS(height, 5); cmpUnitMotionFlying.OnUpdate({ "turnLength": 0 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 0.25); TS_ASSERT_EQUALS(height, 5); cmpUnitMotionFlying.OnUpdate({ "turnLength": 500 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 0); TS_ASSERT_EQUALS(height, 5); // Stay cmpUnitMotionFlying.OnUpdate({ "turnLength": 300 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 0); TS_ASSERT_EQUALS(height, 5); cmpUnitMotionFlying.OnUpdate({ "turnLength": 0 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 0); TS_ASSERT_EQUALS(height, 5); cmpUnitMotionFlying.OnUpdate({ "turnLength": 900 }); TS_ASSERT_EQUALS(cmpUnitMotionFlying.GetCurrentSpeed(), 0); TS_ASSERT_EQUALS(height, 5); Index: ps/trunk/source/simulation2/components/CCmpUnitMotion.cpp =================================================================== --- ps/trunk/source/simulation2/components/CCmpUnitMotion.cpp (revision 22276) +++ ps/trunk/source/simulation2/components/CCmpUnitMotion.cpp (revision 22277) @@ -1,1817 +1,1818 @@ /* 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 . */ #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/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 /** * When advancing along the long path, and picking a new waypoint to move * towards, we'll pick one that's up to this far from the unit's current * position (to minimise the effects of grid-constrained movement) */ static const entity_pos_t WAYPOINT_ADVANCE_MAX = entity_pos_t::FromInt(TERRAIN_TILE_SIZE*8); /** * Min/Max range to restrict short path queries to. (Larger ranges are 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*2); static const entity_pos_t SHORT_PATH_MAX_SEARCH_RANGE = entity_pos_t::FromInt(TERRAIN_TILE_SIZE*9); /** * 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); /** * When short-pathing, and the short-range pathfinder failed to return a path, * Assume we are at destination if we are closer than this distance to the target * And we have no target entity. * This is somewhat arbitrary, but setting a too big distance means units might lose sight of their end goal too much; */ static const entity_pos_t SHORT_PATH_GOAL_RADIUS = entity_pos_t::FromInt(TERRAIN_TILE_SIZE*2); /** * 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); /** * If we're following a target entity, * we will recompute our path if the target has moved * more than this distance from where we last pathed to. */ static const entity_pos_t CHECK_TARGET_MOVEMENT_MIN_DELTA = entity_pos_t::FromInt(TERRAIN_TILE_SIZE*4); /** * If we're following as part of a formation, * but can't move to our assigned target point in a straight line, * we will recompute our path if the target has moved * more than this distance from where we last pathed to. */ static const entity_pos_t CHECK_TARGET_MOVEMENT_MIN_DELTA_FORMATION = entity_pos_t::FromInt(TERRAIN_TILE_SIZE*1); /** * If we're following something but it's more than this distance away along * our path, then don't bother trying to repath regardless of how much it has * moved, until we get this close to the end of our old path. */ static const entity_pos_t CHECK_TARGET_MOVEMENT_AT_MAX_DIST = entity_pos_t::FromInt(TERRAIN_TILE_SIZE*16); /** * If we're following something and the angle between the (straight-line) directions to its previous target * position and its present target position is greater than a given angle, recompute the path even far away * (i.e. even if CHECK_TARGET_MOVEMENT_AT_MAX_DIST condition is not fulfilled). The actual check is done * on the cosine of this angle, with a PI/6 angle. */ static const fixed CHECK_TARGET_MOVEMENT_MIN_COS = fixed::FromInt(866)/1000; 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_TemplateRunSpeedMultiplier; + 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_RunSpeedMultiplier; + fixed m_WalkSpeed, m_RunMultiplier; bool m_Moving; bool m_FacePointAfterMove; enum State { /* * Not moving at all. */ STATE_IDLE, /* * Not moving at all. Will go to IDLE next turn. * (This one-turn delay is a hack to fix animation timings.) */ STATE_STOPPING, /* * Member of a formation. * Pathing to the target (depending on m_PathState). * Target is m_TargetEntity plus m_TargetOffset. */ STATE_FORMATIONMEMBER_PATH, /* * Individual unit or formation controller. * Pathing to the target (depending on m_PathState). * Target is m_TargetPos, m_TargetMinRange, m_TargetMaxRange; * if m_TargetEntity is not INVALID_ENTITY then m_TargetPos is tracking it. */ STATE_INDIVIDUAL_PATH, STATE_MAX }; u8 m_State; enum PathState { /* * There is no path. * (This should only happen in IDLE and STOPPING.) */ PATHSTATE_NONE, /* * We have an outstanding long path request. * No paths are usable yet, so we can't move anywhere. */ PATHSTATE_WAITING_REQUESTING_LONG, /* * We have an outstanding short path request. * m_LongPath is valid. * m_ShortPath is not yet valid, so we can't move anywhere. */ PATHSTATE_WAITING_REQUESTING_SHORT, /* * We are following our path, and have no path requests. * m_LongPath and m_ShortPath are valid. */ PATHSTATE_FOLLOWING, /* * We are following our path, and have an outstanding long path request. * (This is because our target moved a long way and we need to recompute * the whole path). * m_LongPath and m_ShortPath are valid. */ PATHSTATE_FOLLOWING_REQUESTING_LONG, /* * We are following our path, and have an outstanding short path request. * (This is because our target moved and we've got a new long path * which we need to follow). * m_LongPath is valid; m_ShortPath is valid but obsolete. */ PATHSTATE_FOLLOWING_REQUESTING_SHORT, PATHSTATE_MAX }; u8 m_PathState; u32 m_ExpectedPathTicket; // asynchronous request ID we're waiting for, or 0 if none entity_id_t m_TargetEntity; CFixedVector2D m_TargetPos; CFixedVector2D m_TargetOffset; entity_pos_t m_TargetMinRange; entity_pos_t m_TargetMaxRange; - // Actual unit speed, after technology and ratio + // 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; - // Convenience variable to avoid recomputing the ratio every time. Synchronised. - fixed m_SpeedRatio; // 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; // Motion planning u8 m_Tries; // how many tries we've done to get to our current Final Goal. PathGoal m_FinalGoal; 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_Moving = false; m_FacePointAfterMove = true; m_WalkSpeed = m_TemplateWalkSpeed = m_Speed = paramNode.GetChild("WalkSpeed").ToFixed(); - m_SpeedRatio = fixed::FromInt(1); + m_SpeedMultiplier = fixed::FromInt(1); m_CurSpeed = fixed::Zero(); - m_RunSpeedMultiplier = m_TemplateRunSpeedMultiplier = fixed::FromInt(1); + m_RunMultiplier = m_TemplateRunMultiplier = fixed::FromInt(1); if (paramNode.GetChild("RunMultiplier").IsOk()) - m_RunSpeedMultiplier = m_TemplateRunSpeedMultiplier = paramNode.GetChild("RunMultiplier").ToFixed(); + 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_State = STATE_IDLE; m_PathState = PATHSTATE_NONE; m_ExpectedPathTicket = 0; m_Tries = 0; m_TargetEntity = INVALID_ENTITY; m_FinalGoal.type = PathGoal::POINT; m_DebugOverlayEnabled = false; } virtual void Deinit() { } template void SerializeCommon(S& serialize) { serialize.NumberU8("state", m_State, 0, STATE_MAX-1); serialize.NumberU8("path state", m_PathState, 0, PATHSTATE_MAX-1); serialize.StringASCII("pass class", m_PassClassName, 0, 64); serialize.NumberU32_Unbounded("ticket", m_ExpectedPathTicket); serialize.NumberU32_Unbounded("target entity", m_TargetEntity); serialize.NumberFixed_Unbounded("target pos x", m_TargetPos.X); serialize.NumberFixed_Unbounded("target pos y", m_TargetPos.Y); serialize.NumberFixed_Unbounded("target offset x", m_TargetOffset.X); serialize.NumberFixed_Unbounded("target offset y", m_TargetOffset.Y); serialize.NumberFixed_Unbounded("target min range", m_TargetMinRange); serialize.NumberFixed_Unbounded("target max range", m_TargetMaxRange); - serialize.NumberFixed_Unbounded("speed ratio", m_SpeedRatio); + serialize.NumberFixed_Unbounded("speed multiplier", m_SpeedMultiplier); serialize.NumberFixed_Unbounded("current speed", m_CurSpeed); serialize.Bool("moving", m_Moving); serialize.Bool("facePointAfterMove", m_FacePointAfterMove); serialize.NumberU8("tries", m_Tries, 0, 255); SerializeVector()(serialize, "long path", m_LongPath.m_Waypoints); SerializeVector()(serialize, "short path", m_ShortPath.m_Waypoints); SerializeGoal()(serialize, "goal", m_FinalGoal); } 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_RunSpeedMultiplier = cmpValueModificationManager->ApplyModifications(L"UnitMotion/RunMultiplier", m_TemplateRunSpeedMultiplier, GetEntityId()); + m_RunMultiplier = cmpValueModificationManager->ApplyModifications(L"UnitMotion/RunMultiplier", m_TemplateRunMultiplier, GetEntityId()); - // Adjust our speed. UnitMotion cannot know if this speed is on purpose or not so always adjust and let unitAI and such adapt. - m_SpeedRatio = std::min(m_SpeedRatio, m_RunSpeedMultiplier); - m_Speed = m_SpeedRatio.Multiply(GetWalkSpeed()); + // 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 IsMoving() const { return m_Moving; } - virtual fixed GetSpeedRatio() const + virtual fixed GetSpeedMultiplier() const { - return m_SpeedRatio; + return m_SpeedMultiplier; } - virtual fixed GetRunSpeedMultiplier() const + virtual void SetSpeedMultiplier(fixed multiplier) { - return m_RunSpeedMultiplier; - } - - virtual void SetSpeedRatio(fixed ratio) - { - m_SpeedRatio = std::min(ratio, m_RunSpeedMultiplier); - m_Speed = m_SpeedRatio.Multiply(GetWalkSpeed()); + 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 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); virtual bool IsInPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t minRange, entity_pos_t maxRange) const; virtual bool MoveToTargetRange(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange); virtual bool IsInTargetRange(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) const; virtual void MoveToFormationOffset(entity_id_t target, entity_pos_t x, entity_pos_t z); virtual void FaceTowardsPoint(entity_pos_t x, entity_pos_t z); virtual void StopMoving() { m_Moving = false; m_ExpectedPathTicket = 0; m_State = STATE_STOPPING; m_PathState = PATHSTATE_NONE; 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 { return m_State == STATE_FORMATIONMEMBER_PATH; } entity_id_t GetGroup() const { return IsFormationMember() ? m_TargetEntity : GetEntityId(); } bool HasValidPath() const { return m_PathState == PATHSTATE_FOLLOWING || m_PathState == PATHSTATE_FOLLOWING_REQUESTING_LONG || m_PathState == PATHSTATE_FOLLOWING_REQUESTING_SHORT; } void StartFailed() { StopMoving(); m_State = STATE_IDLE; // don't go through the STOPPING state since we never even started CmpPtr cmpObstruction(GetEntityHandle()); if (cmpObstruction) cmpObstruction->SetMovingFlag(false); CMessageMotionChanged msg(true, true); GetSimContext().GetComponentManager().PostMessage(GetEntityId(), msg); } void MoveFailed() { StopMoving(); CmpPtr cmpObstruction(GetEntityHandle()); if (cmpObstruction) cmpObstruction->SetMovingFlag(false); CMessageMotionChanged msg(false, true); GetSimContext().GetComponentManager().PostMessage(GetEntityId(), msg); } void StartSucceeded() { CmpPtr cmpObstruction(GetEntityHandle()); if (cmpObstruction) cmpObstruction->SetMovingFlag(true); m_Moving = true; CMessageMotionChanged msg(true, false); GetSimContext().GetComponentManager().PostMessage(GetEntityId(), msg); } void MoveSucceeded() { m_Moving = false; CmpPtr cmpObstruction(GetEntityHandle()); if (cmpObstruction) cmpObstruction->SetMovingFlag(false); // No longer moving, so speed is 0. m_CurSpeed = fixed::Zero(); CMessageMotionChanged msg(false, false); GetSimContext().GetComponentManager().PostMessage(GetEntityId(), msg); } bool MoveToPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t minRange, entity_pos_t maxRange, entity_id_t target); /** * 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); /** * 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; /** * 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; /** * Attempts to replace the current path with a straight line to the goal, * if this goal is a point, is close enough and the route is not obstructed. */ bool TryGoingStraightToGoalPoint(const CFixedVector2D& from); /** * Attempts to replace the current path with a straight line to the target * entity, if it's close enough and the route is not obstructed. */ bool TryGoingStraightToTargetEntity(const CFixedVector2D& from); /** * Returns whether the target entity has moved more than minDelta since our * last path computations, and we're close enough to it to care. */ bool CheckTargetMovement(const CFixedVector2D& from, entity_pos_t minDelta); /** * Update goal position if moving target */ void UpdateFinalGoal(); /** * Returns whether we are close enough to the target to assume it's a good enough * position to stop. */ bool ShouldConsiderOurselvesAtDestination(const CFixedVector2D& from); /** * Returns whether the length of the given path, plus the distance from * 'from' to the first waypoints, it shorter than minDistance. */ bool PathIsShort(const WaypointPath& path, const CFixedVector2D& from, entity_pos_t minDistance) 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. * noTarget is true only when used inside tryGoingStraightToTargetEntity, * in which case we do not want the target obstruction otherwise it would always fail */ ControlGroupMovementObstructionFilter GetObstructionFilter(bool noTarget = false) const; /** * Start moving to the given goal, from our current position 'from'. * Might go in a straight line immediately, or might start an asynchronous * path request. */ void BeginPathing(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); /** * 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) void CCmpUnitMotion::PathResult(u32 ticket, const WaypointPath& path) { // reset our state for sanity. CmpPtr cmpObstruction(GetEntityHandle()); if (cmpObstruction) cmpObstruction->SetMovingFlag(false); m_Moving = false; // Ignore obsolete path requests if (ticket != m_ExpectedPathTicket) return; m_ExpectedPathTicket = 0; // we don't expect to get this result again // Check that we are still able to do something with that path CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) { if (m_PathState == PATHSTATE_WAITING_REQUESTING_LONG || m_PathState == PATHSTATE_WAITING_REQUESTING_SHORT) StartFailed(); else if (m_PathState == PATHSTATE_FOLLOWING_REQUESTING_LONG || m_PathState == PATHSTATE_FOLLOWING_REQUESTING_SHORT) StopMoving(); return; } if (m_PathState == PATHSTATE_WAITING_REQUESTING_LONG || m_PathState == PATHSTATE_FOLLOWING_REQUESTING_LONG) { m_LongPath = path; // If we are following a path, leave the old m_ShortPath so we can carry on following it // until a new short path has been computed if (m_PathState == PATHSTATE_WAITING_REQUESTING_LONG) m_ShortPath.m_Waypoints.clear(); // 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), so if we're stuck and the user clicks // close enough to the unit then we can probably get unstuck if (m_LongPath.m_Waypoints.empty()) m_LongPath.m_Waypoints.emplace_back(Waypoint{ m_FinalGoal.x, m_FinalGoal.z }); if (!HasValidPath()) StartSucceeded(); m_PathState = PATHSTATE_FOLLOWING; if (cmpObstruction) cmpObstruction->SetMovingFlag(true); m_Moving = true; } else if (m_PathState == PATHSTATE_WAITING_REQUESTING_SHORT || m_PathState == PATHSTATE_FOLLOWING_REQUESTING_SHORT) { m_ShortPath = path; // If there's no waypoints then we couldn't get near the target if (m_ShortPath.m_Waypoints.empty()) { // If we're globally following a long path, try to remove the next waypoint, it might be obstructed // If not, and we are not in a formation, retry // unless we are close to our target and we don't have a target entity. // This makes sure that units don't clump too much when they are not in a formation and tasked to move. if (m_LongPath.m_Waypoints.size() > 1) m_LongPath.m_Waypoints.pop_back(); else if (IsFormationMember()) { m_Moving = false; CMessageMotionChanged msg(true, true); GetSimContext().GetComponentManager().PostMessage(GetEntityId(), msg); return; } CMessageMotionChanged msg(false, false); GetSimContext().GetComponentManager().PostMessage(GetEntityId(), msg); CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return; CFixedVector2D pos = cmpPosition->GetPosition2D(); if (ShouldConsiderOurselvesAtDestination(pos)) return; UpdateFinalGoal(); RequestLongPath(pos, m_FinalGoal); m_PathState = PATHSTATE_WAITING_REQUESTING_LONG; return; } // else we could, so reset our number of tries. m_Tries = 0; // Now we've got a short path that we can follow if (!HasValidPath()) StartSucceeded(); m_PathState = PATHSTATE_FOLLOWING; if (cmpObstruction) cmpObstruction->SetMovingFlag(true); m_Moving = true; } else LOGWARNING("unexpected PathResult (%u %d %d)", GetEntityId(), m_State, m_PathState); } void CCmpUnitMotion::Move(fixed dt) { PROFILE("Move"); if (m_State == STATE_STOPPING) { m_State = STATE_IDLE; MoveSucceeded(); return; } if (m_State == STATE_IDLE) return; switch (m_PathState) { case PATHSTATE_NONE: { // If we're not pathing, do nothing return; } case PATHSTATE_WAITING_REQUESTING_LONG: case PATHSTATE_WAITING_REQUESTING_SHORT: { // If we're waiting for a path and don't have one yet, do nothing return; } case PATHSTATE_FOLLOWING: case PATHSTATE_FOLLOWING_REQUESTING_SHORT: case PATHSTATE_FOLLOWING_REQUESTING_LONG: { // 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()); if (!cmpPathfinder) return; CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return; CFixedVector2D initialPos = cmpPosition->GetPosition2D(); // 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 if (m_PathState == PATHSTATE_FOLLOWING) TryGoingStraightToTargetEntity(initialPos); // Keep track of the current unit's position during the update CFixedVector2D pos = initialPos; fixed basicSpeed = m_Speed; // If in formation, run to keep up; otherwise just walk if (IsFormationMember()) - basicSpeed = m_Speed.Multiply(m_RunSpeedMultiplier); + 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); bool wasObstructed = false; // 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 (m_ShortPath.m_Waypoints.empty() && m_LongPath.m_Waypoints.empty()) break; CFixedVector2D target; if (m_ShortPath.m_Waypoints.empty()) target = CFixedVector2D(m_LongPath.m_Waypoints.back().x, m_LongPath.m_Waypoints.back().z); else target = CFixedVector2D(m_ShortPath.m_Waypoints.back().x, m_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 (m_ShortPath.m_Waypoints.empty()) m_LongPath.m_Waypoints.pop_back(); else m_ShortPath.m_Waypoints.pop_back(); continue; } else { // Error - path was obstructed wasObstructed = true; break; } } 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 wasObstructed = true; // Error - path was obstructed break; } } // Update the Position component after our movement (if we actually moved anywhere) if (pos != initialPos) { 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. m_CurSpeed = cmpPosition->GetDistanceTravelled() / dt; } if (wasObstructed) { // Oops, we hit something (very likely another unit). // This is when we might easily get stuck wrongly. // check if we've arrived. if (ShouldConsiderOurselvesAtDestination(pos)) return; // If we still have long waypoints, try and compute a short path // This will get us around units, amongst others. // However in some cases a long waypoint will be in located in the obstruction of // an idle unit. In that case, we need to scrap that waypoint or we might never be able to reach it. // I am not sure why this happens but the following code seems to work. if (!m_LongPath.m_Waypoints.empty()) { CmpPtr cmpObstructionManager(GetSystemEntity()); if (cmpObstructionManager) { // create a fake obstruction to represent our waypoint. ICmpObstructionManager::ObstructionSquare square; square.hh = m_Clearance; square.hw = m_Clearance; square.u = CFixedVector2D(entity_pos_t::FromInt(1),entity_pos_t::FromInt(0)); square.v = CFixedVector2D(entity_pos_t::FromInt(0),entity_pos_t::FromInt(1)); square.x = m_LongPath.m_Waypoints.back().x; square.z = m_LongPath.m_Waypoints.back().z; std::vector unitOnGoal; // don't ignore moving units as those might be units like us, ie not really moving. cmpObstructionManager->GetUnitsOnObstruction(square, unitOnGoal, GetObstructionFilter(), true); if (!unitOnGoal.empty()) m_LongPath.m_Waypoints.pop_back(); } if (!m_LongPath.m_Waypoints.empty()) { PathGoal goal; if (m_LongPath.m_Waypoints.size() > 1 || m_FinalGoal.DistanceToPoint(pos) > LONG_PATH_MIN_DIST) goal = { PathGoal::POINT, m_LongPath.m_Waypoints.back().x, m_LongPath.m_Waypoints.back().z }; else { UpdateFinalGoal(); goal = m_FinalGoal; m_LongPath.m_Waypoints.clear(); CFixedVector2D target = goal.NearestPointOnGoal(pos); m_LongPath.m_Waypoints.emplace_back(Waypoint{ target.X, target.Y }); } RequestShortPath(pos, goal, true); m_PathState = PATHSTATE_WAITING_REQUESTING_SHORT; return; } } // Else, just entirely recompute UpdateFinalGoal(); BeginPathing(pos, m_FinalGoal); // potential TODO: We could switch the short-range pathfinder for something else entirely. return; } // We successfully moved along our path, until running out of // waypoints or time. if (m_PathState == PATHSTATE_FOLLOWING) { // If we're not currently computing any new paths: if (m_LongPath.m_Waypoints.empty() && m_ShortPath.m_Waypoints.empty()) { if (IsFormationMember()) { // We've reached our assigned position. If the controller // is idle, send a notification in case it should disband, // otherwise continue following the formation next turn. CmpPtr cmpUnitMotion(GetSimContext(), m_TargetEntity); if (cmpUnitMotion && !cmpUnitMotion->IsMoving()) { CmpPtr cmpObstruction(GetEntityHandle()); if (cmpObstruction) cmpObstruction->SetMovingFlag(false); m_Moving = false; CMessageMotionChanged msg(false, false); GetSimContext().GetComponentManager().PostMessage(GetEntityId(), msg); } } else { // check if target was reached in case of a moving target CmpPtr cmpUnitMotion(GetSimContext(), m_TargetEntity); if (cmpUnitMotion && cmpUnitMotion->IsMoving() && MoveToTargetRange(m_TargetEntity, m_TargetMinRange, m_TargetMaxRange)) return; // Not in formation, so just finish moving StopMoving(); m_State = STATE_IDLE; MoveSucceeded(); if (m_FacePointAfterMove) FaceTowardsPointFromPos(pos, m_FinalGoal.x, m_FinalGoal.z); // TODO: if the goal was a square building, we ought to point towards the // nearest point on the square, not towards its center } } // If we have a target entity, and we're not miles away from the end of // our current path, and the target moved enough, then recompute our // whole path if (IsFormationMember()) CheckTargetMovement(pos, CHECK_TARGET_MOVEMENT_MIN_DELTA_FORMATION); else CheckTargetMovement(pos, CHECK_TARGET_MOVEMENT_MIN_DELTA); } } } } bool CCmpUnitMotion::ComputeTargetPosition(CFixedVector2D& out) const { if (m_TargetEntity == INVALID_ENTITY) return false; CmpPtr cmpPosition(GetSimContext(), m_TargetEntity); if (!cmpPosition || !cmpPosition->IsInWorld()) return false; if (m_TargetOffset.IsZero()) { // No offset, just return the position directly out = cmpPosition->GetPosition2D(); } else { // There is an offset, so compute it relative to orientation entity_angle_t angle = cmpPosition->GetRotation().Y; CFixedVector2D offset = m_TargetOffset.Rotate(angle); out = cmpPosition->GetPosition2D() + offset; } return true; } bool CCmpUnitMotion::TryGoingStraightToGoalPoint(const CFixedVector2D& from) { // Make sure the goal is a point (and not a point-like target like a formation controller) if (m_FinalGoal.type != PathGoal::POINT || m_TargetEntity != INVALID_ENTITY) return false; // Fail if the goal is too far away CFixedVector2D goalPos(m_FinalGoal.x, m_FinalGoal.z); if ((goalPos - from).CompareLength(DIRECT_PATH_RANGE) > 0) return false; CmpPtr cmpPathfinder(GetSystemEntity()); if (!cmpPathfinder) return false; // Check if there's any collisions on that route 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::TryGoingStraightToTargetEntity(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 = m_FinalGoal; 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 if (!cmpPathfinder->CheckMovement(GetObstructionFilter(true), from.X, from.Y, goalPos.X, goalPos.Y, m_Clearance, m_PassClass)) return false; // That route is okay, so update our path m_FinalGoal = goal; 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::CheckTargetMovement(const CFixedVector2D& from, entity_pos_t minDelta) { CFixedVector2D targetPos; if (!ComputeTargetPosition(targetPos)) return false; // Fail unless the target has moved enough CFixedVector2D oldTargetPos(m_FinalGoal.x, m_FinalGoal.z); if ((targetPos - oldTargetPos).CompareLength(minDelta) < 0) return false; CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return false; CFixedVector2D pos = cmpPosition->GetPosition2D(); CFixedVector2D oldDir = (oldTargetPos - pos); CFixedVector2D newDir = (targetPos - pos); oldDir.Normalize(); newDir.Normalize(); // Fail unless we're close enough to the target to care about its movement // and the angle between the (straight-line) directions of the previous and new target positions is small if (oldDir.Dot(newDir) > CHECK_TARGET_MOVEMENT_MIN_COS && !PathIsShort(m_LongPath, from, CHECK_TARGET_MOVEMENT_AT_MAX_DIST)) return false; // Fail if the target is no longer visible to this entity's owner // (in which case we'll continue moving to its last known location, // unless it comes back into view before we reach that location) CmpPtr cmpOwnership(GetEntityHandle()); if (cmpOwnership) { CmpPtr cmpRangeManager(GetSystemEntity()); if (cmpRangeManager && cmpRangeManager->GetLosVisibility(m_TargetEntity, cmpOwnership->GetOwner()) == ICmpRangeManager::VIS_HIDDEN) return false; } // The target moved and we need to update our current path; // change the goal here and expect our caller to start the path request m_FinalGoal.x = targetPos.X; m_FinalGoal.z = targetPos.Y; RequestLongPath(from, m_FinalGoal); m_PathState = PATHSTATE_FOLLOWING_REQUESTING_LONG; return true; } void CCmpUnitMotion::UpdateFinalGoal() { if (m_TargetEntity == INVALID_ENTITY) return; CmpPtr cmpUnitMotion(GetSimContext(), m_TargetEntity); if (!cmpUnitMotion) return; if (IsFormationMember()) return; CFixedVector2D targetPos; if (!ComputeTargetPosition(targetPos)) return; m_FinalGoal.x = targetPos.X; m_FinalGoal.z = targetPos.Y; } bool CCmpUnitMotion::ShouldConsiderOurselvesAtDestination(const CFixedVector2D& from) { if (m_TargetEntity != INVALID_ENTITY || m_FinalGoal.DistanceToPoint(from) > SHORT_PATH_GOAL_RADIUS) return false; StopMoving(); MoveSucceeded(); if (m_FacePointAfterMove) FaceTowardsPointFromPos(from, m_FinalGoal.x, m_FinalGoal.z); return true; } bool CCmpUnitMotion::PathIsShort(const WaypointPath& path, const CFixedVector2D& from, entity_pos_t minDistance) const { CFixedVector2D prev = from; entity_pos_t distLeft = minDistance; for (ssize_t i = (ssize_t)path.m_Waypoints.size()-1; i >= 0; --i) { // Check if the next path segment is longer than the requested minimum CFixedVector2D waypoint(path.m_Waypoints[i].x, path.m_Waypoints[i].z); CFixedVector2D delta = waypoint - prev; if (delta.CompareLength(distLeft) > 0) return false; // Still short enough - prepare to check the next segment distLeft -= delta.Length(); prev = waypoint; } // Reached the end of the path before exceeding minDistance 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(bool noTarget) const { entity_id_t group = noTarget ? m_TargetEntity : GetGroup(); return ControlGroupMovementObstructionFilter(ShouldAvoidMovingUnits(), group); } void CCmpUnitMotion::BeginPathing(const CFixedVector2D& from, const PathGoal& goal) { // reset our state for sanity. m_ExpectedPathTicket = 0; CmpPtr cmpObstruction(GetEntityHandle()); if (cmpObstruction) cmpObstruction->SetMovingFlag(false); m_Moving = false; m_PathState = PATHSTATE_NONE; #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 }); m_PathState = PATHSTATE_FOLLOWING; return; } #endif // If we're aiming at a target entity and it's 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 (TryGoingStraightToTargetEntity(from)) { if (!HasValidPath()) StartSucceeded(); m_PathState = PATHSTATE_FOLLOWING; return; } // Same thing applies to non-entity points if (TryGoingStraightToGoalPoint(from)) { if (!HasValidPath()) StartSucceeded(); m_PathState = PATHSTATE_FOLLOWING; 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. if (goal.DistanceToPoint(from) < LONG_PATH_MIN_DIST) { // add our final goal as a long range waypoint so we don't forget // where we are going if the short-range pathfinder returns // an aborted path. m_LongPath.m_Waypoints.clear(); CFixedVector2D target = m_FinalGoal.NearestPointOnGoal(from); m_LongPath.m_Waypoints.emplace_back(Waypoint{ target.X, target.Y }); m_PathState = PATHSTATE_WAITING_REQUESTING_SHORT; RequestShortPath(from, goal, true); } else { m_PathState = PATHSTATE_WAITING_REQUESTING_LONG; 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 = 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; // wrapping around on m_Tries isn't really a problem so don't check for overflow. fixed searchRange = std::max(SHORT_PATH_MIN_SEARCH_RANGE * ++m_Tries, goal.DistanceToPoint(from)); if (goal.type != PathGoal::POINT && searchRange < goal.hw && searchRange < SHORT_PATH_MIN_SEARCH_RANGE * 2) searchRange = std::min(goal.hw, SHORT_PATH_MIN_SEARCH_RANGE * 2); if (searchRange > SHORT_PATH_MAX_SEARCH_RANGE) searchRange = SHORT_PATH_MAX_SEARCH_RANGE; m_ExpectedPathTicket = cmpPathfinder->ComputeShortPathAsync(from.X, from.Y, m_Clearance, searchRange, goal, m_PassClass, avoidMovingUnits, GetGroup(), GetEntityId()); } bool CCmpUnitMotion::MoveToPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t minRange, entity_pos_t maxRange) { return MoveToPointRange(x, z, minRange, maxRange, INVALID_ENTITY); } bool CCmpUnitMotion::MoveToPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t minRange, entity_pos_t maxRange, entity_id_t target) { PROFILE("MoveToPointRange"); CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return false; CFixedVector2D pos = cmpPosition->GetPosition2D(); PathGoal goal; goal.x = x; goal.z = z; if (minRange.IsZero() && maxRange.IsZero()) { // Non-ranged movement: // Head directly for the goal goal.type = PathGoal::POINT; } else { // Ranged movement: entity_pos_t distance = (pos - CFixedVector2D(x, z)).Length(); if (distance < minRange) { // Too close to target - move outwards to a circle // that's slightly larger than the min range goal.type = PathGoal::INVERTED_CIRCLE; goal.hw = minRange + Pathfinding::GOAL_DELTA; } else if (maxRange >= entity_pos_t::Zero() && distance > maxRange) { // Too far from target - move inwards to a circle // that's slightly smaller than the max range goal.type = PathGoal::CIRCLE; goal.hw = maxRange - Pathfinding::GOAL_DELTA; // If maxRange was abnormally small, // collapse the circle into a point if (goal.hw <= entity_pos_t::Zero()) goal.type = PathGoal::POINT; } else { // We're already in range - no need to move anywhere if (m_FacePointAfterMove) FaceTowardsPointFromPos(pos, x, z); return false; } } m_State = STATE_INDIVIDUAL_PATH; m_TargetEntity = target; m_TargetOffset = CFixedVector2D(); m_TargetMinRange = minRange; m_TargetMaxRange = maxRange; m_FinalGoal = goal; m_Tries = 0; BeginPathing(pos, goal); return true; } bool CCmpUnitMotion::IsInPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t minRange, entity_pos_t maxRange) const { CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return false; CFixedVector2D pos = cmpPosition->GetPosition2D(); bool hasObstruction = false; CmpPtr cmpObstructionManager(GetSystemEntity()); ICmpObstructionManager::ObstructionSquare obstruction; //TODO if (cmpObstructionManager) // hasObstruction = cmpObstructionManager->FindMostImportantObstruction(GetObstructionFilter(), x, z, m_Radius, obstruction); if (minRange.IsZero() && maxRange.IsZero() && hasObstruction) { // Handle the non-ranged mode: CFixedVector2D halfSize(obstruction.hw, obstruction.hh); entity_pos_t distance = Geometry::DistanceToSquare(pos - CFixedVector2D(obstruction.x, obstruction.z), obstruction.u, obstruction.v, halfSize); // See if we're too close to the target square if (distance < minRange) return false; // See if we're close enough to the target square if (maxRange < entity_pos_t::Zero() || distance <= maxRange) return true; return false; } else { entity_pos_t distance = (pos - CFixedVector2D(x, z)).Length(); if (distance < minRange) return false; else if (maxRange >= entity_pos_t::Zero() && distance > maxRange) return false; else return true; } } 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::MoveToTargetRange(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) { PROFILE("MoveToTargetRange"); CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return false; CFixedVector2D pos = cmpPosition->GetPosition2D(); CmpPtr cmpObstructionManager(GetSystemEntity()); if (!cmpObstructionManager) return false; bool hasObstruction = false; ICmpObstructionManager::ObstructionSquare obstruction; CmpPtr cmpObstruction(GetSimContext(), target); if (cmpObstruction) hasObstruction = cmpObstruction->GetObstructionSquare(obstruction); if (!hasObstruction) { // The target didn't have an obstruction or obstruction shape, so treat it as a point instead CmpPtr cmpTargetPosition(GetSimContext(), target); if (!cmpTargetPosition || !cmpTargetPosition->IsInWorld()) return false; CFixedVector2D targetPos = cmpTargetPosition->GetPosition2D(); return MoveToPointRange(targetPos.X, targetPos.Y, minRange, maxRange); } /* * If we're starting outside the maxRange, we need to move closer in. * If we're starting inside the minRange, we need to move further out. * These ranges are measured from the center of this entity to the edge of the target; * we add the goal range onto the size of the target shape to get the goal shape. * (Then we extend it outwards/inwards by a little bit to be sure we'll end up * within the right range, in case of minor numerical inaccuracies.) * * There's a bit of a problem with large square targets: * the pathfinder only lets us move to goals that are squares, but the points an equal * distance from the target make a rounded square shape instead. * * When moving closer, we could shrink the goal radius to 1/sqrt(2) so the goal shape fits entirely * within the desired rounded square, but that gives an unfair advantage to attackers who approach * the target diagonally. * * If the target is small relative to the range (e.g. archers attacking anything), * then we cheat and pretend the target is actually a circle. * (TODO: that probably looks rubbish for things like walls?) * * If the target is large relative to the range (e.g. melee units attacking buildings), * then we multiply maxRange by approx 1/sqrt(2) to guarantee they'll always aim close enough. * (Those units should set minRange to 0 so they'll never be considered *too* close.) */ CFixedVector2D halfSize(obstruction.hw, obstruction.hh); PathGoal goal; goal.x = obstruction.x; goal.z = obstruction.z; entity_pos_t distance = Geometry::DistanceToSquare(pos - CFixedVector2D(obstruction.x, obstruction.z), obstruction.u, obstruction.v, halfSize, true); // Compare with previous obstruction ICmpObstructionManager::ObstructionSquare previousObstruction; cmpObstruction->GetPreviousObstructionSquare(previousObstruction); entity_pos_t previousDistance = Geometry::DistanceToSquare(pos - CFixedVector2D(previousObstruction.x, previousObstruction.z), obstruction.u, obstruction.v, halfSize, true); bool inside = distance.IsZero() && !Geometry::DistanceToSquare(pos - CFixedVector2D(obstruction.x, obstruction.z), obstruction.u, obstruction.v, halfSize).IsZero(); if ((distance < minRange && previousDistance < minRange) || inside) { // Too close to the square - need to move away // Circumscribe the square entity_pos_t circleRadius = halfSize.Length(); entity_pos_t goalDistance = minRange + Pathfinding::GOAL_DELTA; if (ShouldTreatTargetAsCircle(minRange, circleRadius)) { // The target is small relative to our range, so pretend it's a circle goal.type = PathGoal::INVERTED_CIRCLE; goal.hw = circleRadius + goalDistance; } else { goal.type = PathGoal::INVERTED_SQUARE; goal.u = obstruction.u; goal.v = obstruction.v; goal.hw = obstruction.hw + goalDistance; goal.hh = obstruction.hh + goalDistance; } } else if (maxRange < entity_pos_t::Zero() || distance < maxRange || previousDistance < maxRange) { // We're already in range - no need to move anywhere FaceTowardsPointFromPos(pos, goal.x, goal.z); return false; } else { // We might need to move closer: // Circumscribe the square entity_pos_t circleRadius = halfSize.Length(); if (ShouldTreatTargetAsCircle(maxRange, circleRadius)) { // The target is small relative to our range, so pretend it's a circle // Note that the distance to the circle will always be less than // the distance to the square, so the previous "distance < maxRange" // check is still valid (though not sufficient) entity_pos_t circleDistance = (pos - CFixedVector2D(obstruction.x, obstruction.z)).Length() - circleRadius; entity_pos_t previousCircleDistance = (pos - CFixedVector2D(previousObstruction.x, previousObstruction.z)).Length() - circleRadius; if (circleDistance < maxRange || previousCircleDistance < maxRange) { // We're already in range - no need to move anywhere if (m_FacePointAfterMove) FaceTowardsPointFromPos(pos, goal.x, goal.z); return false; } entity_pos_t goalDistance = maxRange - Pathfinding::GOAL_DELTA; goal.type = PathGoal::CIRCLE; goal.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 = (maxRange - Pathfinding::GOAL_DELTA)*2 / 3; // multiply by slightly less than 1/sqrt(2) goal.type = PathGoal::SQUARE; goal.u = obstruction.u; goal.v = obstruction.v; 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 goal.hw = obstruction.hw + delta; goal.hh = obstruction.hh + delta; } } m_State = STATE_INDIVIDUAL_PATH; m_TargetEntity = target; m_TargetOffset = CFixedVector2D(); m_TargetMinRange = minRange; m_TargetMaxRange = maxRange; m_FinalGoal = goal; m_Tries = 0; BeginPathing(pos, goal); return true; } bool CCmpUnitMotion::IsInTargetRange(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) const { // This function closely mirrors MoveToTargetRange - it needs to return true // after that Move has completed CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return false; CFixedVector2D pos = cmpPosition->GetPosition2D(); CmpPtr cmpObstructionManager(GetSystemEntity()); if (!cmpObstructionManager) return false; bool hasObstruction = false; ICmpObstructionManager::ObstructionSquare obstruction; CmpPtr cmpObstruction(GetSimContext(), target); if (cmpObstruction) hasObstruction = cmpObstruction->GetObstructionSquare(obstruction); if (hasObstruction) { CFixedVector2D halfSize(obstruction.hw, obstruction.hh); entity_pos_t distance = Geometry::DistanceToSquare(pos - CFixedVector2D(obstruction.x, obstruction.z), obstruction.u, obstruction.v, halfSize, true); // Compare with previous obstruction ICmpObstructionManager::ObstructionSquare previousObstruction; cmpObstruction->GetPreviousObstructionSquare(previousObstruction); entity_pos_t previousDistance = Geometry::DistanceToSquare(pos - CFixedVector2D(previousObstruction.x, previousObstruction.z), obstruction.u, obstruction.v, halfSize, true); // See if we're too close to the target square bool inside = distance.IsZero() && !Geometry::DistanceToSquare(pos - CFixedVector2D(obstruction.x, obstruction.z), obstruction.u, obstruction.v, halfSize).IsZero(); if ((distance < minRange && previousDistance < minRange) || inside) return false; // See if we're close enough to the target square if (maxRange < entity_pos_t::Zero() || distance <= maxRange || previousDistance <= maxRange) return true; entity_pos_t circleRadius = halfSize.Length(); if (ShouldTreatTargetAsCircle(maxRange, circleRadius)) { // The target is small relative to our range, so pretend it's a circle // and see if we're close enough to that. // Also check circle around previous position. entity_pos_t circleDistance = (pos - CFixedVector2D(obstruction.x, obstruction.z)).Length() - circleRadius; entity_pos_t previousCircleDistance = (pos - CFixedVector2D(previousObstruction.x, previousObstruction.z)).Length() - circleRadius; return circleDistance <= maxRange || previousCircleDistance <= maxRange; } // take minimal clearance required in MoveToTargetRange into account, multiplying by 3/2 for diagonals entity_pos_t maxDist = std::max(maxRange, (m_Clearance + entity_pos_t::FromInt(TERRAIN_TILE_SIZE)/16)*3/2); return distance <= maxDist || previousDistance <= maxDist; } else { CmpPtr cmpTargetPosition(GetSimContext(), target); if (!cmpTargetPosition || !cmpTargetPosition->IsInWorld()) return false; CFixedVector2D targetPos = cmpTargetPosition->GetPreviousPosition2D(); entity_pos_t distance = (pos - targetPos).Length(); return minRange <= distance && (maxRange < entity_pos_t::Zero() || distance <= maxRange); } } void CCmpUnitMotion::MoveToFormationOffset(entity_id_t target, entity_pos_t x, entity_pos_t z) { CmpPtr cmpPosition(GetSimContext(), target); if (!cmpPosition || !cmpPosition->IsInWorld()) return; CFixedVector2D pos = cmpPosition->GetPosition2D(); PathGoal goal; goal.type = PathGoal::POINT; goal.x = pos.X; goal.z = pos.Y; m_State = STATE_FORMATIONMEMBER_PATH; m_TargetEntity = target; m_TargetOffset = CFixedVector2D(x, z); m_TargetMinRange = entity_pos_t::Zero(); m_TargetMaxRange = entity_pos_t::Zero(); m_FinalGoal = goal; m_Tries = 0; BeginPathing(pos, goal); } 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/ICmpUnitMotion.cpp =================================================================== --- ps/trunk/source/simulation2/components/ICmpUnitMotion.cpp (revision 22276) +++ ps/trunk/source/simulation2/components/ICmpUnitMotion.cpp (revision 22277) @@ -1,147 +1,147 @@ /* 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 . */ #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_CONST_4("IsInPointRange", bool, ICmpUnitMotion, IsInPointRange, entity_pos_t, entity_pos_t, entity_pos_t, entity_pos_t) DEFINE_INTERFACE_METHOD_CONST_3("IsInTargetRange", bool, ICmpUnitMotion, IsInTargetRange, entity_id_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_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("GetRunSpeedMultiplier", fixed, ICmpUnitMotion, GetRunSpeedMultiplier) -DEFINE_INTERFACE_METHOD_1("SetSpeedRatio", void, ICmpUnitMotion, SetSpeedRatio, fixed) DEFINE_INTERFACE_METHOD_CONST_0("IsMoving", bool, ICmpUnitMotion, IsMoving) 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_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 IsInPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t minRange, entity_pos_t maxRange) const { return m_Script.Call("IsInPointRange", x, z, minRange, maxRange); } virtual bool IsInTargetRange(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) const { return m_Script.Call("IsInTargetRange", target, 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 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 void SetSpeedRatio(fixed ratio) - { - m_Script.CallVoid("SetSpeedRatio", ratio); - } - - virtual fixed GetRunSpeedMultiplier() const - { - return m_Script.Call("GetRunSpeedMultiplier"); - } - virtual bool IsMoving() const { return m_Script.Call("IsMoving"); } 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 pass_class_t GetPassabilityClass() const { return m_Script.Call("GetPassabilityClass"); } - virtual fixed GetSpeedRatio() const - { - return m_Script.Call("GetSpeedRatio"); - } - 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 22276) +++ ps/trunk/source/simulation2/components/ICmpUnitMotion.h (revision 22277) @@ -1,154 +1,154 @@ /* 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_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 the unit is already in range, or cannot move anywhere at all, or if there is * some other error, then returns false. * Otherwise, returns true and sends a MotionChanged message after starting to move, * and sends another MotionChanged after finishing moving. * 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; /** * Determine wether the givven point is within the given range, using the same measurement * as MoveToPointRange. */ virtual bool IsInPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t minRange, entity_pos_t maxRange) const = 0; /** * Determine whether the target is within the given range, using the same measurement * as MoveToTargetRange. */ virtual bool IsInTargetRange(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) const = 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 is already in range, or cannot move anywhere at all, or if there is * some other error, then returns false. * Otherwise, returns true and sends a MotionChanged message after starting to move, * and sends another MotionChanged after finishing moving. * 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; /** * 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 current movement speed. + * Get the distance travelled over the last turn. */ virtual fixed GetCurrentSpeed() const = 0; /** * Get whether the unit is moving. */ virtual bool IsMoving() const = 0; /** - * Get how much faster/slower we are than normal. + * Get the unit template walk speed after modifications. */ - virtual fixed GetSpeedRatio() const = 0; + virtual fixed GetWalkSpeed() const = 0; /** - * Get how much faster than our regular speed we can go. + * Get the unit template running (i.e. max) speed after modifications. */ - virtual fixed GetRunSpeedMultiplier() const = 0; + virtual fixed GetRunMultiplier() const = 0; /** - * Set the current movement speed. - * @param speed A ratio of GetWalkSpeed(). + * Returns the ratio of GetSpeed() / GetWalkSpeed(). */ - virtual void SetSpeedRatio(fixed ratio) = 0; + virtual fixed GetSpeedMultiplier() const = 0; /** - * Get the unit theoretical speed in metres per second. - * This is affected by SetSpeedRatio. + * Set the current movement speed. + * @param speed A multiplier of GetWalkSpeed(). */ - virtual fixed GetSpeed() const = 0; + virtual void SetSpeedMultiplier(fixed multiplier) = 0; /** - * Get the unit "raw"/template walk speed after technologies. - * Calls to SetSpeedRatio have no effect on this (as that affects actual speed, not template). + * Get the speed at which the unit intends to move. + * (regardless of whether the unit is moving or not right now). */ - virtual fixed GetWalkSpeed() const = 0; + 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; /** * 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/tools/atlas/GameInterface/ActorViewer.cpp =================================================================== --- ps/trunk/source/tools/atlas/GameInterface/ActorViewer.cpp (revision 22276) +++ ps/trunk/source/tools/atlas/GameInterface/ActorViewer.cpp (revision 22277) @@ -1,557 +1,557 @@ /* 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 . */ #include "precompiled.h" #include "ActorViewer.h" #include "View.h" #include "graphics/ColladaManager.h" #include "graphics/LOSTexture.h" #include "graphics/Unit.h" #include "graphics/Model.h" #include "graphics/ModelDef.h" #include "graphics/ObjectManager.h" #include "graphics/ParticleManager.h" #include "graphics/Patch.h" #include "graphics/SkeletonAnimManager.h" #include "graphics/Terrain.h" #include "graphics/TerrainTextureEntry.h" #include "graphics/TerrainTextureManager.h" #include "graphics/TerritoryTexture.h" #include "graphics/UnitManager.h" #include "graphics/Overlay.h" #include "maths/MathUtil.h" #include "ps/Filesystem.h" #include "ps/CLogger.h" #include "ps/GameSetup/Config.h" #include "ps/ProfileViewer.h" #include "renderer/Renderer.h" #include "renderer/Scene.h" #include "renderer/SkyManager.h" #include "renderer/WaterManager.h" #include "scriptinterface/ScriptInterface.h" #include "simulation2/Simulation2.h" #include "simulation2/components/ICmpOwnership.h" #include "simulation2/components/ICmpPosition.h" #include "simulation2/components/ICmpRangeManager.h" #include "simulation2/components/ICmpTerrain.h" #include "simulation2/components/ICmpUnitMotion.h" #include "simulation2/components/ICmpVisual.h" #include "simulation2/components/ICmpWaterManager.h" #include "simulation2/helpers/Render.h" struct ActorViewerImpl : public Scene { NONCOPYABLE(ActorViewerImpl); public: ActorViewerImpl() : Entity(INVALID_ENTITY), Terrain(), ColladaManager(g_VFS), MeshManager(ColladaManager), SkeletonAnimManager(ColladaManager), UnitManager(), Simulation2(&UnitManager, g_ScriptRuntime, &Terrain), ObjectManager(MeshManager, SkeletonAnimManager, Simulation2), LOSTexture(Simulation2), TerritoryTexture(Simulation2) { UnitManager.SetObjectManager(ObjectManager); } entity_id_t Entity; CStrW CurrentUnitID; CStrW CurrentUnitAnim; float CurrentSpeed; bool WalkEnabled; bool GroundEnabled; bool WaterEnabled; bool ShadowsEnabled; // Whether shadows, sky and water are enabled outside of the actor viewer. bool OldShadows; bool OldSky; bool OldWater; bool SelectionBoxEnabled; bool AxesMarkerEnabled; int PropPointsMode; // 0 disabled, 1 for point markers, 2 for point markers + axes SColor4ub Background; CTerrain Terrain; CColladaManager ColladaManager; CMeshManager MeshManager; CSkeletonAnimManager SkeletonAnimManager; CObjectManager ObjectManager; CUnitManager UnitManager; CSimulation2 Simulation2; CLOSTexture LOSTexture; CTerritoryTexture TerritoryTexture; SOverlayLine SelectionBoxOverlay; SOverlayLine AxesMarkerOverlays[3]; std::vector Props; std::vector PropPointOverlays; // Simplistic implementation of the Scene interface virtual void EnumerateObjects(const CFrustum& frustum, SceneCollector* c) { if (GroundEnabled) { for (ssize_t pj = 0; pj < Terrain.GetPatchesPerSide(); ++pj) for (ssize_t pi = 0; pi < Terrain.GetPatchesPerSide(); ++pi) c->Submit(Terrain.GetPatch(pi, pj)); } CmpPtr cmpVisual(Simulation2, Entity); if (cmpVisual) { // add selection box outlines manually if (SelectionBoxEnabled) { SelectionBoxOverlay.m_Color = CColor(35/255.f, 86/255.f, 188/255.f, .75f); // pretty blue SelectionBoxOverlay.m_Thickness = 2; SimRender::ConstructBoxOutline(cmpVisual->GetSelectionBox(), SelectionBoxOverlay); c->Submit(&SelectionBoxOverlay); } // add origin axis thingy if (AxesMarkerEnabled) { CMatrix3D worldSpaceAxes; // offset from the ground a little bit to prevent fighting with the floor texture (also note: SetTranslation // sets the identity 3x3 transformation matrix, which are the world axes) worldSpaceAxes.SetTranslation(cmpVisual->GetPosition() + CVector3D(0, 0.02f, 0)); SimRender::ConstructAxesMarker(worldSpaceAxes, AxesMarkerOverlays[0], AxesMarkerOverlays[1], AxesMarkerOverlays[2]); c->Submit(&AxesMarkerOverlays[0]); c->Submit(&AxesMarkerOverlays[1]); c->Submit(&AxesMarkerOverlays[2]); } // add prop point overlays if (PropPointsMode > 0 && Props.size() > 0) { PropPointOverlays.clear(); // doesn't clear capacity, but should be ok since the number of prop points is usually pretty limited for (size_t i = 0; i < Props.size(); ++i) { CModel::Prop& prop = Props[i]; if (prop.m_Model) // should always be the case { // prop point positions are automatically updated during animations etc. by CModel::ValidatePosition const CMatrix3D& propCoordSystem = prop.m_Model->GetTransform(); SOverlayLine pointGimbal; pointGimbal.m_Color = CColor(1.f, 0.f, 1.f, 1.f); SimRender::ConstructGimbal(propCoordSystem.GetTranslation(), 0.05f, pointGimbal); PropPointOverlays.push_back(pointGimbal); if (PropPointsMode > 1) { // scale the prop axes coord system down a bit to distinguish them from the main world-space axes markers CMatrix3D displayCoordSystem = propCoordSystem; displayCoordSystem.Scale(0.5f, 0.5f, 0.5f); // revert translation scaling displayCoordSystem._14 = propCoordSystem._14; displayCoordSystem._24 = propCoordSystem._24; displayCoordSystem._34 = propCoordSystem._34; // construct an XYZ axes marker for the prop's coordinate system SOverlayLine xAxis, yAxis, zAxis; SimRender::ConstructAxesMarker(displayCoordSystem, xAxis, yAxis, zAxis); PropPointOverlays.push_back(xAxis); PropPointOverlays.push_back(yAxis); PropPointOverlays.push_back(zAxis); } } } for (size_t i = 0; i < PropPointOverlays.size(); ++i) { c->Submit(&PropPointOverlays[i]); } } } // send a RenderSubmit message so the components can submit their visuals to the renderer Simulation2.RenderSubmit(*c, frustum, false); } virtual CLOSTexture& GetLOSTexture() { return LOSTexture; } virtual CTerritoryTexture& GetTerritoryTexture() { return TerritoryTexture; } /** * Recursively fetches the props of the currently displayed entity model and its submodels, and stores them for rendering. */ void UpdatePropList(); void UpdatePropListRecursive(CModelAbstract* model); }; void ActorViewerImpl::UpdatePropList() { Props.clear(); CmpPtr cmpVisual(Simulation2, Entity); if (cmpVisual) { CUnit* unit = cmpVisual->GetUnit(); if (unit) { CModelAbstract& modelAbstract = unit->GetModel(); UpdatePropListRecursive(&modelAbstract); } } } void ActorViewerImpl::UpdatePropListRecursive(CModelAbstract* modelAbstract) { ENSURE(modelAbstract); CModel* model = modelAbstract->ToCModel(); if (model) { std::vector& modelProps = model->GetProps(); for (CModel::Prop& modelProp : modelProps) { Props.push_back(modelProp); if (modelProp.m_Model) UpdatePropListRecursive(modelProp.m_Model); } } } ActorViewer::ActorViewer() : m(*new ActorViewerImpl()) { m.WalkEnabled = false; m.GroundEnabled = true; m.WaterEnabled = false; m.ShadowsEnabled = g_Renderer.GetOptionBool(CRenderer::OPT_SHADOWS); m.SelectionBoxEnabled = false; m.AxesMarkerEnabled = false; m.PropPointsMode = 0; m.Background = SColor4ub(0, 0, 0, 255); // Create a tiny empty piece of terrain, just so we can put shadows // on it without having to think too hard m.Terrain.Initialize(2, NULL); CTerrainTextureEntry* tex = g_TexMan.FindTexture("whiteness"); if (tex) { for (ssize_t pi = 0; pi < m.Terrain.GetPatchesPerSide(); ++pi) { for (ssize_t pj = 0; pj < m.Terrain.GetPatchesPerSide(); ++pj) { CPatch* patch = m.Terrain.GetPatch(pi, pj); for (ssize_t i = 0; i < PATCH_SIZE; ++i) { for (ssize_t j = 0; j < PATCH_SIZE; ++j) { CMiniPatch& mp = patch->m_MiniPatches[i][j]; mp.Tex = tex; mp.Priority = 0; } } } } } else { debug_warn(L"Failed to load whiteness texture"); } // Prepare the simulation m.Simulation2.LoadDefaultScripts(); m.Simulation2.ResetState(); // Set player data m.Simulation2.SetMapSettings(m.Simulation2.GetPlayerDefaults()); m.Simulation2.LoadPlayerSettings(true); // Tell the simulation we've already loaded the terrain CmpPtr cmpTerrain(m.Simulation2, SYSTEM_ENTITY); if (cmpTerrain) cmpTerrain->ReloadTerrain(false); // Remove FOW since we're in Atlas CmpPtr cmpRangeManager(m.Simulation2, SYSTEM_ENTITY); if (cmpRangeManager) cmpRangeManager->SetLosRevealAll(-1, true); m.Simulation2.InitGame(); } ActorViewer::~ActorViewer() { delete &m; } CSimulation2* ActorViewer::GetSimulation2() { return &m.Simulation2; } entity_id_t ActorViewer::GetEntity() { return m.Entity; } void ActorViewer::UnloadObjects() { m.ObjectManager.UnloadObjects(); } void ActorViewer::SetActor(const CStrW& name, const CStrW& animation, player_id_t playerID) { bool needsAnimReload = false; CStrW id = name; // Recreate the entity, if we don't have one or if the new one is different if (m.Entity == INVALID_ENTITY || id != m.CurrentUnitID) { // Delete the old entity (if any) if (m.Entity != INVALID_ENTITY) { m.Simulation2.DestroyEntity(m.Entity); m.Simulation2.FlushDestroyedEntities(); m.Entity = INVALID_ENTITY; } // Clear particles associated with deleted entity g_Renderer.GetParticleManager().ClearUnattachedEmitters(); // If there's no actor to display, return with nothing loaded if (id.empty()) return; m.Entity = m.Simulation2.AddEntity(L"preview|" + id); if (m.Entity == INVALID_ENTITY) return; CmpPtr cmpPosition(m.Simulation2, m.Entity); if (cmpPosition) { ssize_t c = TERRAIN_TILE_SIZE * m.Terrain.GetPatchesPerSide()*PATCH_SIZE/2; cmpPosition->JumpTo(entity_pos_t::FromInt(c), entity_pos_t::FromInt(c)); cmpPosition->SetYRotation(entity_angle_t::Pi()); } CmpPtr cmpOwnership(m.Simulation2, m.Entity); if (cmpOwnership) cmpOwnership->SetOwner(playerID); needsAnimReload = true; } if (animation != m.CurrentUnitAnim) needsAnimReload = true; if (needsAnimReload) { CStr anim = animation.ToUTF8().LowerCase(); // Emulate the typical simulation animation behaviour float speed; float repeattime = 0.f; if (anim == "walk") { CmpPtr cmpUnitMotion(m.Simulation2, m.Entity); if (cmpUnitMotion) speed = cmpUnitMotion->GetWalkSpeed().ToFloat(); else speed = 7.f; // typical unit speed m.CurrentSpeed = speed; } else if (anim == "run") { CmpPtr cmpUnitMotion(m.Simulation2, m.Entity); if (cmpUnitMotion) - speed = cmpUnitMotion->GetWalkSpeed().ToFloat() * cmpUnitMotion->GetRunSpeedMultiplier().ToFloat(); + speed = cmpUnitMotion->GetWalkSpeed().ToFloat() * cmpUnitMotion->GetRunMultiplier().ToFloat(); else speed = 12.f; // typical unit speed m.CurrentSpeed = speed; } else if (anim == "melee") { speed = 1.f; // speed will be ignored if we have a repeattime m.CurrentSpeed = 0.f; CStr code = "var cmp = Engine.QueryInterface("+CStr::FromUInt(m.Entity)+", IID_Attack); " + "if (cmp) cmp.GetTimers(cmp.GetBestAttack()).repeat; else 0;"; m.Simulation2.GetScriptInterface().Eval(code.c_str(), repeattime); } else { // Play the animation at normal speed, but movement speed is zero speed = 1.f; m.CurrentSpeed = 0.f; } CStr sound; if (anim == "melee") sound = "attack"; else if (anim == "build") sound = "build"; else if (anim.Find("gather_") == 0) sound = anim; CmpPtr cmpVisual(m.Simulation2, m.Entity); if (cmpVisual) { // TODO: SetEntitySelection(anim) cmpVisual->SelectAnimation(anim, false, fixed::FromFloat(speed)); if (repeattime) cmpVisual->SetAnimationSyncRepeat(fixed::FromFloat(repeattime)); } // update prop list for new entity/animation (relies on needsAnimReload also getting called for entire entity changes) m.UpdatePropList(); } m.CurrentUnitID = id; m.CurrentUnitAnim = animation; } void ActorViewer::SetEnabled(bool enabled) { if (enabled) { // Set shadows, sky and water. m.OldShadows = g_Renderer.GetOptionBool(CRenderer::OPT_SHADOWS); g_Renderer.SetOptionBool(CRenderer::OPT_SHADOWS, m.ShadowsEnabled); m.OldSky = g_Renderer.GetSkyManager()->GetRenderSky(); g_Renderer.GetSkyManager()->SetRenderSky(false); m.OldWater = g_Renderer.GetWaterManager()->m_RenderWater; g_Renderer.GetWaterManager()->m_RenderWater = m.WaterEnabled; } else { // Restore the old renderer state g_Renderer.SetOptionBool(CRenderer::OPT_SHADOWS, m.OldShadows); g_Renderer.GetSkyManager()->SetRenderSky(m.OldSky); g_Renderer.GetWaterManager()->m_RenderWater = m.OldWater; } } void ActorViewer::SetBackgroundColor(const SColor4ub& color) { m.Background = color; m.Terrain.SetBaseColor(color); } void ActorViewer::SetWalkEnabled(bool enabled) { m.WalkEnabled = enabled; } void ActorViewer::SetGroundEnabled(bool enabled) { m.GroundEnabled = enabled; } void ActorViewer::SetWaterEnabled(bool enabled) { m.WaterEnabled = enabled; // Adjust water level entity_pos_t waterLevel = entity_pos_t::FromFloat(enabled ? 10.f : 0.f); CmpPtr cmpWaterManager(m.Simulation2, SYSTEM_ENTITY); if (cmpWaterManager) cmpWaterManager->SetWaterLevel(waterLevel); } void ActorViewer::SetShadowsEnabled(bool enabled) { m.ShadowsEnabled = enabled; } void ActorViewer::SetBoundingBoxesEnabled(bool enabled) { m.SelectionBoxEnabled = enabled; } void ActorViewer::SetAxesMarkerEnabled(bool enabled) { m.AxesMarkerEnabled = enabled; } void ActorViewer::SetPropPointsMode(int mode) { m.PropPointsMode = mode; } void ActorViewer::SetStatsEnabled(bool enabled) { if (enabled) g_ProfileViewer.ShowTable("renderer"); else g_ProfileViewer.ShowTable(""); } void ActorViewer::Render() { m.Terrain.MakeDirty(RENDERDATA_UPDATE_COLOR); g_Renderer.SetClearColor(m.Background); // Set simulation context for rendering purposes g_Renderer.SetSimulation(&m.Simulation2); g_Renderer.BeginFrame(); // Find the centre of the interesting region, in the middle of the patch // and half way up the model (assuming there is one) CVector3D centre; CmpPtr cmpVisual(m.Simulation2, m.Entity); if (cmpVisual) cmpVisual->GetBounds().GetCentre(centre); else centre.Y = 0.f; centre.X = centre.Z = TERRAIN_TILE_SIZE * m.Terrain.GetPatchesPerSide()*PATCH_SIZE/2; CCamera camera = AtlasView::GetView_Actor()->GetCamera(); camera.m_Orientation.Translate(centre.X, centre.Y, centre.Z); camera.UpdateFrustum(); g_Renderer.SetSceneCamera(camera, camera); g_Renderer.RenderScene(m); glDisable(GL_DEPTH_TEST); g_Logger->Render(); g_ProfileViewer.RenderProfile(); glEnable(GL_DEPTH_TEST); g_Renderer.EndFrame(); ogl_WarnIfError(); } void ActorViewer::Update(float simFrameLength, float realFrameLength) { m.Simulation2.Update((int)(simFrameLength*1000)); m.Simulation2.Interpolate(simFrameLength, 0, realFrameLength); if (m.WalkEnabled && m.CurrentSpeed) { CmpPtr cmpPosition(m.Simulation2, m.Entity); if (cmpPosition) { // Move the model by speed*simFrameLength forwards float z = cmpPosition->GetPosition().Z.ToFloat(); z -= m.CurrentSpeed*simFrameLength; // Wrap at the edges, so it doesn't run off into the horizon ssize_t c = TERRAIN_TILE_SIZE * m.Terrain.GetPatchesPerSide()*PATCH_SIZE/2; if (z < c - TERRAIN_TILE_SIZE*PATCH_SIZE * 0.1f) z = c + TERRAIN_TILE_SIZE*PATCH_SIZE * 0.1f; cmpPosition->JumpTo(cmpPosition->GetPosition().X, entity_pos_t::FromFloat(z)); } } }