Index: ps/trunk/binaries/data/mods/public/globalscripts/Templates.js =================================================================== --- ps/trunk/binaries/data/mods/public/globalscripts/Templates.js (revision 25952) +++ ps/trunk/binaries/data/mods/public/globalscripts/Templates.js (revision 25953) @@ -1,624 +1,626 @@ /** * Loads history and gameplay data of all civs. * * @param selectableOnly {boolean} - Only load civs that can be selected * in the gamesetup. Scenario maps might set non-selectable civs. */ function loadCivFiles(selectableOnly) { let propertyNames = [ "Code", "Culture", "Name", "Emblem", "History", "Music", "CivBonuses", "StartEntities", "Formations", "AINames", "SkirmishReplacements", "SelectableInGameSetup"]; let civData = {}; for (let filename of Engine.ListDirectoryFiles("simulation/data/civs/", "*.json", false)) { let data = Engine.ReadJSONFile(filename); for (let prop of propertyNames) if (data[prop] === undefined) throw new Error(filename + " doesn't contain " + prop); if (!selectableOnly || data.SelectableInGameSetup) civData[data.Code] = data; } return civData; } /** * @return {string[]} - All the classes for this identity template. */ function GetIdentityClasses(template) { let classString = ""; if (template.Classes && template.Classes._string) classString += " " + template.Classes._string; if (template.VisibleClasses && template.VisibleClasses._string) classString += " " + template.VisibleClasses._string; if (template.Rank) classString += " " + template.Rank; return classString.length > 1 ? classString.substring(1).split(" ") : []; } /** * Gets an array with all classes for this identity template * that should be shown in the GUI */ function GetVisibleIdentityClasses(template) { return template.VisibleClasses && template.VisibleClasses._string ? template.VisibleClasses._string.split(" ") : []; } /** * Check if a given list of classes matches another list of classes. * Useful f.e. for checking identity classes. * * @param classes - List of the classes to check against. * @param match - Either a string in the form * "Class1 Class2+Class3" * where spaces are handled as OR and '+'-signs as AND, * and ! is handled as NOT, thus Class1+!Class2 = Class1 AND NOT Class2. * Or a list in the form * [["Class1"], ["Class2", "Class3"]] * where the outer list is combined as OR, and the inner lists are AND-ed. * Or a hybrid format containing a list of strings, where the list is * combined as OR, and the strings are split by space and '+' and AND-ed. * * @return undefined if there are no classes or no match object * true if the the logical combination in the match object matches the classes * false otherwise. */ function MatchesClassList(classes, match) { if (!match || !classes) return undefined; // Transform the string to an array if (typeof match == "string") match = match.split(/\s+/); for (let sublist of match) { // If the elements are still strings, split them by space or by '+' if (typeof sublist == "string") sublist = sublist.split(/[+\s]+/); if (sublist.every(c => (c[0] == "!" && classes.indexOf(c.substr(1)) == -1) || (c[0] != "!" && classes.indexOf(c) != -1))) return true; } return false; } /** * Gets the value originating at the value_path as-is, with no modifiers applied. * * @param {Object} template - A valid template as returned from a template loader. * @param {string} value_path - Route to value within the xml template structure. * @return {number} */ function GetBaseTemplateDataValue(template, value_path) { let current_value = template; for (let property of value_path.split("/")) current_value = current_value[property] || 0; return +current_value; } /** * Gets the value originating at the value_path with the modifiers dictated by the mod_key applied. * * @param {Object} template - A valid template as returned from a template loader. * @param {string} value_path - Route to value within the xml template structure. * @param {string} mod_key - Tech modification key, if different from value_path. * @param {number} player - Optional player id. * @param {Object} modifiers - Value modifiers from auto-researched techs, unit upgrades, * etc. Optional as only used if no player id provided. * @return {number} Modifier altered value. */ function GetModifiedTemplateDataValue(template, value_path, mod_key, player, modifiers={}) { let current_value = GetBaseTemplateDataValue(template, value_path); mod_key = mod_key || value_path; if (player) current_value = ApplyValueModificationsToTemplate(mod_key, current_value, player, template); else if (modifiers && modifiers[mod_key]) current_value = GetTechModifiedProperty(modifiers[mod_key], GetIdentityClasses(template.Identity), current_value); // Using .toFixed() to get around spidermonkey's treatment of numbers (3 * 1.1 = 3.3000000000000003 for instance). return +current_value.toFixed(8); } /** * Get information about a template with or without technology modifications. * * NOTICE: The data returned here should have the same structure as * the object returned by GetEntityState and GetExtendedEntityState! * * @param {Object} template - A valid template as returned by the template loader. * @param {number} player - An optional player id to get the technology modifications * of properties. * @param {Object} auraTemplates - In the form of { key: { "auraName": "", "auraDescription": "" } }. * @param {Object} modifiers - Modifications from auto-researched techs, unit upgrades * etc. Optional as only used if there's no player * id provided. */ function GetTemplateDataHelper(template, player, auraTemplates, modifiers = {}) { // Return data either from template (in tech tree) or sim state (ingame). // @param {string} value_path - Route to the value within the template. // @param {string} mod_key - Modification key, if not the same as the value_path. let getEntityValue = function(value_path, mod_key) { return GetModifiedTemplateDataValue(template, value_path, mod_key, player, modifiers); }; let ret = {}; if (template.Resistance) { // Don't show Foundation resistance. ret.resistance = {}; if (template.Resistance.Entity) { if (template.Resistance.Entity.Damage) { ret.resistance.Damage = {}; for (let damageType in template.Resistance.Entity.Damage) ret.resistance.Damage[damageType] = getEntityValue("Resistance/Entity/Damage/" + damageType); } if (template.Resistance.Entity.Capture) ret.resistance.Capture = getEntityValue("Resistance/Entity/Capture"); if (template.Resistance.Entity.ApplyStatus) { ret.resistance.ApplyStatus = {}; for (let statusEffect in template.Resistance.Entity.ApplyStatus) ret.resistance.ApplyStatus[statusEffect] = { "blockChance": getEntityValue("Resistance/Entity/ApplyStatus/" + statusEffect + "/BlockChance"), "duration": getEntityValue("Resistance/Entity/ApplyStatus/" + statusEffect + "/Duration") }; } } } let getAttackEffects = (temp, path) => { let effects = {}; if (temp.Capture) effects.Capture = getEntityValue(path + "/Capture"); if (temp.Damage) { effects.Damage = {}; for (let damageType in temp.Damage) effects.Damage[damageType] = getEntityValue(path + "/Damage/" + damageType); } if (temp.ApplyStatus) effects.ApplyStatus = temp.ApplyStatus; return effects; }; if (template.Attack) { ret.attack = {}; for (let type in template.Attack) { let getAttackStat = function(stat) { return getEntityValue("Attack/" + type + "/" + stat); }; ret.attack[type] = { "attackName": { "name": template.Attack[type].AttackName._string || template.Attack[type].AttackName, "context": template.Attack[type].AttackName["@context"] }, "minRange": getAttackStat("MinRange"), "maxRange": getAttackStat("MaxRange"), "elevationBonus": getAttackStat("ElevationBonus") }; ret.attack[type].elevationAdaptedRange = Math.sqrt(ret.attack[type].maxRange * (2 * ret.attack[type].elevationBonus + ret.attack[type].maxRange)); ret.attack[type].repeatTime = getAttackStat("RepeatTime"); if (template.Attack[type].Projectile) ret.attack[type].friendlyFire = template.Attack[type].Projectile.FriendlyFire == "true"; Object.assign(ret.attack[type], getAttackEffects(template.Attack[type], "Attack/" + type)); if (template.Attack[type].Splash) { ret.attack[type].splash = { "friendlyFire": template.Attack[type].Splash.FriendlyFire != "false", "shape": template.Attack[type].Splash.Shape, }; Object.assign(ret.attack[type].splash, getAttackEffects(template.Attack[type].Splash, "Attack/" + type + "/Splash")); } } } if (template.DeathDamage) { ret.deathDamage = { "friendlyFire": template.DeathDamage.FriendlyFire != "false", }; Object.assign(ret.deathDamage, getAttackEffects(template.DeathDamage, "DeathDamage")); } if (template.Auras && auraTemplates) { ret.auras = {}; for (let auraID of template.Auras._string.split(/\s+/)) ret.auras[auraID] = GetAuraDataHelper(auraTemplates[auraID]); } if (template.BuildingAI) ret.buildingAI = { "defaultArrowCount": Math.round(getEntityValue("BuildingAI/DefaultArrowCount")), "garrisonArrowMultiplier": getEntityValue("BuildingAI/GarrisonArrowMultiplier"), "maxArrowCount": Math.round(getEntityValue("BuildingAI/MaxArrowCount")) }; if (template.BuildRestrictions) { // required properties ret.buildRestrictions = { "placementType": template.BuildRestrictions.PlacementType, "territory": template.BuildRestrictions.Territory, "category": template.BuildRestrictions.Category, }; // optional properties if (template.BuildRestrictions.Distance) { ret.buildRestrictions.distance = { "fromClass": template.BuildRestrictions.Distance.FromClass, }; if (template.BuildRestrictions.Distance.MinDistance) ret.buildRestrictions.distance.min = getEntityValue("BuildRestrictions/Distance/MinDistance"); if (template.BuildRestrictions.Distance.MaxDistance) ret.buildRestrictions.distance.max = getEntityValue("BuildRestrictions/Distance/MaxDistance"); } } if (template.TrainingRestrictions) { ret.trainingRestrictions = { "category": template.TrainingRestrictions.Category }; if (template.TrainingRestrictions.MatchLimit) ret.trainingRestrictions.matchLimit = +template.TrainingRestrictions.MatchLimit; } if (template.Cost) { ret.cost = {}; for (let resCode in template.Cost.Resources) ret.cost[resCode] = getEntityValue("Cost/Resources/" + resCode); if (template.Cost.Population) ret.cost.population = getEntityValue("Cost/Population"); if (template.Cost.BuildTime) ret.cost.time = getEntityValue("Cost/BuildTime"); } if (template.Footprint) { ret.footprint = { "height": template.Footprint.Height }; if (template.Footprint.Square) ret.footprint.square = { "width": +template.Footprint.Square["@width"], "depth": +template.Footprint.Square["@depth"] }; else if (template.Footprint.Circle) ret.footprint.circle = { "radius": +template.Footprint.Circle["@radius"] }; else warn("GetTemplateDataHelper(): Unrecognized Footprint type"); } if (template.Garrisonable) ret.garrisonable = { "size": getEntityValue("Garrisonable/Size") }; if (template.GarrisonHolder) { ret.garrisonHolder = { "buffHeal": getEntityValue("GarrisonHolder/BuffHeal") }; if (template.GarrisonHolder.Max) ret.garrisonHolder.capacity = getEntityValue("GarrisonHolder/Max"); } if (template.Heal) ret.heal = { "health": getEntityValue("Heal/Health"), "range": getEntityValue("Heal/Range"), "interval": getEntityValue("Heal/Interval") }; if (template.ResourceGatherer) { ret.resourceGatherRates = {}; let baseSpeed = getEntityValue("ResourceGatherer/BaseSpeed"); for (let type in template.ResourceGatherer.Rates) ret.resourceGatherRates[type] = getEntityValue("ResourceGatherer/Rates/"+ type) * baseSpeed; } if (template.ResourceDropsite) ret.resourceDropsite = { "types": template.ResourceDropsite.Types.split(" ") }; if (template.ResourceTrickle) { ret.resourceTrickle = { "interval": +template.ResourceTrickle.Interval, "rates": {} }; for (let type in template.ResourceTrickle.Rates) ret.resourceTrickle.rates[type] = getEntityValue("ResourceTrickle/Rates/" + type); } if (template.Loot) { ret.loot = {}; for (let type in template.Loot) ret.loot[type] = getEntityValue("Loot/"+ type); } if (template.Obstruction) { ret.obstruction = { "active": ("" + template.Obstruction.Active == "true"), "blockMovement": ("" + template.Obstruction.BlockMovement == "true"), "blockPathfinding": ("" + template.Obstruction.BlockPathfinding == "true"), "blockFoundation": ("" + template.Obstruction.BlockFoundation == "true"), "blockConstruction": ("" + template.Obstruction.BlockConstruction == "true"), "disableBlockMovement": ("" + template.Obstruction.DisableBlockMovement == "true"), "disableBlockPathfinding": ("" + template.Obstruction.DisableBlockPathfinding == "true"), "shape": {} }; if (template.Obstruction.Static) { ret.obstruction.shape.type = "static"; ret.obstruction.shape.width = +template.Obstruction.Static["@width"]; ret.obstruction.shape.depth = +template.Obstruction.Static["@depth"]; } else if (template.Obstruction.Unit) { ret.obstruction.shape.type = "unit"; ret.obstruction.shape.radius = +template.Obstruction.Unit["@radius"]; } else ret.obstruction.shape.type = "cluster"; } if (template.Pack) ret.pack = { "state": template.Pack.State, "time": getEntityValue("Pack/Time"), }; if (template.Population && template.Population.Bonus) ret.population = { "bonus": getEntityValue("Population/Bonus") }; if (template.Health) ret.health = Math.round(getEntityValue("Health/Max")); if (template.Identity) { ret.selectionGroupName = template.Identity.SelectionGroupName; ret.name = { "specific": (template.Identity.SpecificName || template.Identity.GenericName), "generic": template.Identity.GenericName }; ret.icon = template.Identity.Icon; ret.tooltip = template.Identity.Tooltip; ret.requiredTechnology = template.Identity.RequiredTechnology; ret.visibleIdentityClasses = GetVisibleIdentityClasses(template.Identity); ret.nativeCiv = template.Identity.Civ; } if (template.UnitMotion) { + const walkSpeed = getEntityValue("UnitMotion/WalkSpeed"); ret.speed = { - "walk": getEntityValue("UnitMotion/WalkSpeed"), + "walk": walkSpeed, + "run": walkSpeed, + "acceleration": getEntityValue("UnitMotion/Acceleration") }; - ret.speed.run = getEntityValue("UnitMotion/WalkSpeed"); if (template.UnitMotion.RunMultiplier) ret.speed.run *= getEntityValue("UnitMotion/RunMultiplier"); } if (template.Upgrade) { ret.upgrades = []; for (let upgradeName in template.Upgrade) { let upgrade = template.Upgrade[upgradeName]; let cost = {}; if (upgrade.Cost) for (let res in upgrade.Cost) cost[res] = getEntityValue("Upgrade/" + upgradeName + "/Cost/" + res, "Upgrade/Cost/" + res); if (upgrade.Time) cost.time = getEntityValue("Upgrade/" + upgradeName + "/Time", "Upgrade/Time"); ret.upgrades.push({ "entity": upgrade.Entity, "tooltip": upgrade.Tooltip, "cost": cost, "icon": upgrade.Icon || undefined, "requiredTechnology": upgrade.RequiredTechnology || undefined }); } } if (template.ProductionQueue) { ret.techCostMultiplier = {}; for (let res in template.ProductionQueue.TechCostMultiplier) ret.techCostMultiplier[res] = getEntityValue("ProductionQueue/TechCostMultiplier/" + res); } if (template.Trader) ret.trader = { "GainMultiplier": getEntityValue("Trader/GainMultiplier") }; if (template.Treasure) { ret.treasure = { "collectTime": getEntityValue("Treasure/CollectTime"), "resources": {} }; for (let resource in template.Treasure.Resources) ret.treasure.resources[resource] = getEntityValue("Treasure/Resources/" + resource); } if (template.TurretHolder) ret.turretHolder = { "turretPoints": template.TurretHolder.TurretPoints }; if (template.Upkeep) { ret.upkeep = { "interval": +template.Upkeep.Interval, "rates": {} }; for (let type in template.Upkeep.Rates) ret.upkeep.rates[type] = getEntityValue("Upkeep/Rates/" + type); } if (template.WallSet) { ret.wallSet = { "templates": { "tower": template.WallSet.Templates.Tower, "gate": template.WallSet.Templates.Gate, "fort": template.WallSet.Templates.Fort || "structures/" + template.Identity.Civ + "/fortress", "long": template.WallSet.Templates.WallLong, "medium": template.WallSet.Templates.WallMedium, "short": template.WallSet.Templates.WallShort }, "maxTowerOverlap": +template.WallSet.MaxTowerOverlap, "minTowerOverlap": +template.WallSet.MinTowerOverlap }; if (template.WallSet.Templates.WallEnd) ret.wallSet.templates.end = template.WallSet.Templates.WallEnd; if (template.WallSet.Templates.WallCurves) ret.wallSet.templates.curves = template.WallSet.Templates.WallCurves.split(/\s+/); } if (template.WallPiece) ret.wallPiece = { "length": +template.WallPiece.Length, "angle": +(template.WallPiece.Orientation || 1) * Math.PI, "indent": +(template.WallPiece.Indent || 0), "bend": +(template.WallPiece.Bend || 0) * Math.PI }; return ret; } /** * Get basic information about a technology template. * @param {Object} template - A valid template as obtained by loading the tech JSON file. * @param {string} civ - Civilization for which the tech requirements should be calculated. */ function GetTechnologyBasicDataHelper(template, civ) { return { "name": { "generic": template.genericName }, "icon": template.icon ? "technologies/" + template.icon : undefined, "description": template.description, "reqs": DeriveTechnologyRequirements(template, civ), "modifications": template.modifications, "affects": template.affects, "replaces": template.replaces }; } /** * Get information about a technology template. * @param {Object} template - A valid template as obtained by loading the tech JSON file. * @param {string} civ - Civilization for which the specific name and tech requirements should be returned. */ function GetTechnologyDataHelper(template, civ, resources) { let ret = GetTechnologyBasicDataHelper(template, civ); if (template.specificName) ret.name.specific = template.specificName[civ] || template.specificName.generic; ret.cost = { "time": template.researchTime ? +template.researchTime : 0 }; for (let type of resources.GetCodes()) ret.cost[type] = +(template.cost && template.cost[type] || 0); ret.tooltip = template.tooltip; ret.requirementsTooltip = template.requirementsTooltip || ""; return ret; } /** * Get information about an aura template. * @param {object} template - A valid template as obtained by loading the aura JSON file. */ function GetAuraDataHelper(template) { return { "name": { "generic": template.auraName, }, "description": template.auraDescription || null, "modifications": template.modifications, "radius": template.radius || null, }; } function calculateCarriedResources(carriedResources, tradingGoods) { var resources = {}; if (carriedResources) for (let resource of carriedResources) resources[resource.type] = (resources[resource.type] || 0) + resource.amount; if (tradingGoods && tradingGoods.amount) resources[tradingGoods.type] = (resources[tradingGoods.type] || 0) + (tradingGoods.amount.traderGain || 0) + (tradingGoods.amount.market1Gain || 0) + (tradingGoods.amount.market2Gain || 0); return resources; } /** * Remove filter prefix (mirage, corpse, etc) from template name. * * ie. filter|dir/to/template -> dir/to/template */ function removeFiltersFromTemplateName(templateName) { return templateName.split("|").pop(); } Index: ps/trunk/binaries/data/mods/public/gui/common/tooltips.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/common/tooltips.js (revision 25952) +++ ps/trunk/binaries/data/mods/public/gui/common/tooltips.js (revision 25953) @@ -1,1229 +1,1235 @@ var g_TooltipTextFormats = { "unit": { "font": "sans-10", "color": "orange" }, "header": { "font": "sans-bold-13" }, "body": { "font": "sans-13" }, "comma": { "font": "sans-12" }, "namePrimaryBig": { "font": "sans-bold-16" }, "namePrimarySmall": { "font": "sans-bold-12" }, "nameSecondary": { "font": "sans-bold-16" } }; var g_SpecificNamesPrimary = Engine.ConfigDB_GetValue("user", "gui.session.howtoshownames") == 0 || Engine.ConfigDB_GetValue("user", "gui.session.howtoshownames") == 2; var g_ShowSecondaryNames = Engine.ConfigDB_GetValue("user", "gui.session.howtoshownames") == 0 || Engine.ConfigDB_GetValue("user", "gui.session.howtoshownames") == 1; function initDisplayedNames() { registerConfigChangeHandler(changes => { if (changes.has("gui.session.howtoshownames")) updateDisplayedNames(); }); } /** * String of four spaces to be used as indentation in gui strings. */ var g_Indent = " "; var g_DamageTypesMetadata = new DamageTypesMetadata(); var g_StatusEffectsMetadata = new StatusEffectsMetadata(); /** * If true, always shows whether the splash damage deals friendly fire. * Otherwise display the friendly fire tooltip only if it does. */ var g_AlwaysDisplayFriendlyFire = false; function getCostTypes() { return g_ResourceData.GetCodes().concat(["population", "time"]); } function resourceIcon(resource) { return '[icon="icon_' + resource + '"]'; } function resourceNameFirstWord(type) { return translateWithContext("firstWord", g_ResourceData.GetNames()[type]); } function resourceNameWithinSentence(type) { return translateWithContext("withinSentence", g_ResourceData.GetNames()[type]); } /** * Format resource amounts to proper english and translate (for example: "200 food, 100 wood and 300 metal"). */ function getLocalizedResourceAmounts(resources) { let amounts = g_ResourceData.GetCodes() .filter(type => !!resources[type]) .map(type => sprintf(translate("%(amount)s %(resourceType)s"), { "amount": resources[type], "resourceType": resourceNameWithinSentence(type) })); if (amounts.length < 2) return amounts.join(); let lastAmount = amounts.pop(); return sprintf(translate("%(previousAmounts)s and %(lastAmount)s"), { // Translation: This comma is used for separating first to penultimate elements in an enumeration. "previousAmounts": amounts.join(translate(", ")), "lastAmount": lastAmount }); } function bodyFont(text) { return setStringTags(text, g_TooltipTextFormats.body); } function headerFont(text) { return setStringTags(text, g_TooltipTextFormats.header); } function unitFont(text) { return setStringTags(text, g_TooltipTextFormats.unit); } function commaFont(text) { return setStringTags(text, g_TooltipTextFormats.comma); } function getSecondsString(seconds) { return sprintf(translatePlural("%(time)s %(second)s", "%(time)s %(second)s", seconds), { "time": seconds, "second": unitFont(translatePlural("second", "seconds", seconds)) }); } /** * Entity templates have a `Tooltip` tag in the Identity component. * (The contents of which are copied to a `tooltip` attribute in globalscripts.) * * Technologies have a `tooltip` attribute. */ function getEntityTooltip(template) { if (!template.tooltip) return ""; return bodyFont(template.tooltip); } /** * Technologies have a `description` attribute, and Auras have an `auraDescription` * attribute, which becomes `description`. * * (For technologies, this happens in globalscripts.) * * (For auras, this happens either in the Auras component (for session gui) or * reference/common/load.js (for Reference Suite gui)) */ function getDescriptionTooltip(template) { if (!template.description) return ""; return bodyFont(template.description); } /** * Entity templates have a `History` tag in the Identity component. * (The contents of which are copied to a `history` attribute in globalscripts.) */ function getHistoryTooltip(template) { if (!template.history) return ""; return bodyFont(template.history); } function getHealthTooltip(template) { if (!template.health) return ""; return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Health:")), "details": template.health }); } function getCurrentHealthTooltip(entState, label) { if (!entState.maxHitpoints) return ""; return sprintf(translate("%(healthLabel)s %(current)s / %(max)s"), { "healthLabel": headerFont(label || translate("Health:")), "current": Math.round(entState.hitpoints), "max": Math.round(entState.maxHitpoints) }); } function getCurrentCaptureTooltip(entState, label) { if (!entState.maxCapturePoints) return ""; return sprintf(translate("%(captureLabel)s %(current)s / %(max)s"), { "captureLabel": headerFont(label || translate("Capture points:")), "current": Math.round(entState.capturePoints[entState.player]), "max": Math.round(entState.maxCapturePoints) }); } /** * Converts an resistance level into the actual reduction percentage. */ function resistanceLevelToPercentageString(level) { return sprintf(translate("%(percentage)s%%"), { "percentage": (100 - Math.round(Math.pow(0.9, level) * 100)) }); } function getResistanceTooltip(template) { if (!template.resistance) return ""; let details = []; if (template.resistance.Damage) details.push(getDamageResistanceTooltip(template.resistance.Damage)); if (template.resistance.Capture) details.push(getCaptureResistanceTooltip(template.resistance.Capture)); if (template.resistance.ApplyStatus) details.push(getStatusEffectsResistanceTooltip(template.resistance.ApplyStatus)); return details.length ? sprintf(translate("%(label)s\n%(details)s"), { "label": headerFont(translate("Resistance:")), "details": g_Indent + details.join("\n" + g_Indent) }) : ""; } function getDamageResistanceTooltip(resistanceTypeTemplate) { if (!resistanceTypeTemplate) return ""; return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Damage:")), "details": g_DamageTypesMetadata.sort(Object.keys(resistanceTypeTemplate)).map( dmgType => sprintf(translate("%(damage)s %(damageType)s %(resistancePercentage)s"), { "damage": resistanceTypeTemplate[dmgType].toFixed(1), "damageType": unitFont(translateWithContext("damage type", g_DamageTypesMetadata.getName(dmgType))), "resistancePercentage": '[font="sans-10"]' + sprintf(translate("(%(resistancePercentage)s)"), { "resistancePercentage": resistanceLevelToPercentageString(resistanceTypeTemplate[dmgType]) }) + '[/font]' }) ).join(commaFont(translate(", "))) }); } function getCaptureResistanceTooltip(resistanceTypeTemplate) { if (!resistanceTypeTemplate) return ""; return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Capture:")), "details": sprintf(translate("%(damage)s %(damageType)s %(resistancePercentage)s"), { "damage": resistanceTypeTemplate.toFixed(1), "damageType": unitFont(translateWithContext("damage type", "Capture")), "resistancePercentage": '[font="sans-10"]' + sprintf(translate("(%(resistancePercentage)s)"), { "resistancePercentage": resistanceLevelToPercentageString(resistanceTypeTemplate) }) + '[/font]' }) }); } function getStatusEffectsResistanceTooltip(resistanceTypeTemplate) { if (!resistanceTypeTemplate) return ""; return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Status Effects:")), "details": Object.keys(resistanceTypeTemplate).map( statusEffect => { if (resistanceTypeTemplate[statusEffect].blockChance == 1) return sprintf(translate("Blocks %(name)s"), { "name": unitFont(translateWithContext("status effect", g_StatusEffectsMetadata.getName(statusEffect))) }); if (resistanceTypeTemplate[statusEffect].blockChance == 0) return sprintf(translate("%(name)s %(details)s"), { "name": unitFont(translateWithContext("status effect", g_StatusEffectsMetadata.getName(statusEffect))), "details": sprintf(translate("Duration reduction: %(durationReduction)s%%"), { "durationReduction": (100 - resistanceTypeTemplate[statusEffect].duration * 100) }) }); if (resistanceTypeTemplate[statusEffect].duration == 1) return sprintf(translate("%(name)s %(details)s"), { "name": unitFont(translateWithContext("status effect", g_StatusEffectsMetadata.getName(statusEffect))), "details": sprintf(translate("Blocks: %(blockPercentage)s%%"), { "blockPercentage": resistanceTypeTemplate[statusEffect].blockChance * 100 }) }); return sprintf(translate("%(name)s %(details)s"), { "name": unitFont(translateWithContext("status effect", g_StatusEffectsMetadata.getName(statusEffect))), "details": sprintf(translate("Blocks: %(blockPercentage)s%%, Duration reduction: %(durationReduction)s%%"), { "blockPercentage": resistanceTypeTemplate[statusEffect].blockChance * 100, "durationReduction": (100 - resistanceTypeTemplate[statusEffect].duration * 100) }) }); } ).join(commaFont(translate(", "))) }); } function attackRateDetails(interval, projectiles) { if (!interval) return ""; if (projectiles === 0) return translate("Garrison to fire arrows"); let attackRateString = getSecondsString(interval / 1000); let header = headerFont(translate("Interval:")); if (projectiles && +projectiles > 1) { header = headerFont(translate("Rate:")); let projectileString = sprintf(translatePlural("%(projectileCount)s %(projectileName)s", "%(projectileCount)s %(projectileName)s", projectiles), { "projectileCount": projectiles, "projectileName": unitFont(translatePlural("arrow", "arrows", projectiles)) }); attackRateString = sprintf(translate("%(projectileString)s / %(attackRateString)s"), { "projectileString": projectileString, "attackRateString": attackRateString }); } return sprintf(translate("%(label)s %(details)s"), { "label": header, "details": attackRateString }); } function rangeDetails(attackTypeTemplate) { if (!attackTypeTemplate.maxRange) return ""; let rangeTooltipString = { "relative": { // Translation: For example: Range: 2 to 10 (+2) meters "minRange": translate("%(rangeLabel)s %(minRange)s to %(maxRange)s (%(relativeRange)s) %(rangeUnit)s"), // Translation: For example: Range: 10 (+2) meters "no-minRange": translate("%(rangeLabel)s %(maxRange)s (%(relativeRange)s) %(rangeUnit)s"), }, "non-relative": { // Translation: For example: Range: 2 to 10 meters "minRange": translate("%(rangeLabel)s %(minRange)s to %(maxRange)s %(rangeUnit)s"), // Translation: For example: Range: 10 meters "no-minRange": translate("%(rangeLabel)s %(maxRange)s %(rangeUnit)s"), } }; let minRange = Math.round(attackTypeTemplate.minRange); let maxRange = Math.round(attackTypeTemplate.maxRange); let realRange = attackTypeTemplate.elevationAdaptedRange; let relativeRange = realRange ? Math.round(realRange - maxRange) : 0; return sprintf(rangeTooltipString[relativeRange ? "relative" : "non-relative"][minRange ? "minRange" : "no-minRange"], { "rangeLabel": headerFont(translate("Range:")), "minRange": minRange, "maxRange": maxRange, "relativeRange": relativeRange > 0 ? sprintf(translate("+%(number)s"), { "number": relativeRange }) : relativeRange, "rangeUnit": unitFont(minRange || relativeRange ? // Translation: For example "0.5 to 1 meters", "1 (+1) meters" or "1 to 2 (+3) meters" translate("meters") : translatePlural("meter", "meters", maxRange)) }); } function damageDetails(damageTemplate) { if (!damageTemplate) return ""; return g_DamageTypesMetadata.sort(Object.keys(damageTemplate).filter(dmgType => damageTemplate[dmgType])).map( dmgType => sprintf(translate("%(damage)s %(damageType)s"), { "damage": (+damageTemplate[dmgType]).toFixed(1), "damageType": unitFont(translateWithContext("damage type", g_DamageTypesMetadata.getName(dmgType))) })).join(commaFont(translate(", "))); } function captureDetails(captureTemplate) { if (!captureTemplate) return ""; return sprintf(translate("%(amount)s %(name)s"), { "amount": (+captureTemplate).toFixed(1), "name": unitFont(translateWithContext("damage type", "Capture")) }); } function splashDetails(splashTemplate) { let splashLabel = sprintf(headerFont(translate("%(splashShape)s Splash")), { "splashShape": translate(splashTemplate.shape) }); let splashDamageTooltip = sprintf(translate("%(label)s: %(effects)s"), { "label": splashLabel, "effects": attackEffectsDetails(splashTemplate) }); if (g_AlwaysDisplayFriendlyFire || splashTemplate.friendlyFire) splashDamageTooltip += commaFont(translate(", ")) + sprintf(translate("Friendly Fire: %(enabled)s"), { "enabled": splashTemplate.friendlyFire ? translate("Yes") : translate("No") }); return splashDamageTooltip; } function applyStatusDetails(applyStatusTemplate) { if (!applyStatusTemplate) return ""; return sprintf(translate("gives %(name)s"), { "name": Object.keys(applyStatusTemplate).map(x => unitFont(translateWithContext("status effect", g_StatusEffectsMetadata.getName(x))) ).join(commaFont(translate(", "))), }); } function attackEffectsDetails(attackTypeTemplate) { if (!attackTypeTemplate) return ""; let effects = [ captureDetails(attackTypeTemplate.Capture || undefined), damageDetails(attackTypeTemplate.Damage || undefined), applyStatusDetails(attackTypeTemplate.ApplyStatus || undefined) ]; return effects.filter(effect => effect).join(commaFont(translate(", "))); } function getAttackTooltip(template) { if (!template.attack) return ""; let tooltips = []; for (let attackType in template.attack) { // Slaughter is used to kill animals, so do not show it. if (attackType == "Slaughter") continue; let attackTypeTemplate = template.attack[attackType]; let attackLabel = sprintf(headerFont(translate("%(attackType)s")), { "attackType": translateWithContext(attackTypeTemplate.attackName.context || "Name of an attack, usually the weapon.", attackTypeTemplate.attackName.name) }); let projectiles; // Use either current rate from simulation or default count if the sim is not running. // TODO: This ought to be extended to include units which fire multiple projectiles. if (template.buildingAI) projectiles = template.buildingAI.arrowCount || template.buildingAI.defaultArrowCount; let splashTemplate = attackTypeTemplate.splash; // Show the effects of status effects below. let statusEffectsDetails = []; if (attackTypeTemplate.ApplyStatus) for (let status in attackTypeTemplate.ApplyStatus) statusEffectsDetails.push("\n" + g_Indent + g_Indent + getStatusEffectsTooltip(status, attackTypeTemplate.ApplyStatus[status], true)); statusEffectsDetails = statusEffectsDetails.join(""); tooltips.push(sprintf(translate("%(attackLabel)s: %(effects)s, %(range)s, %(rate)s%(statusEffects)s%(splash)s"), { "attackLabel": attackLabel, "effects": attackEffectsDetails(attackTypeTemplate), "range": rangeDetails(attackTypeTemplate), "rate": attackRateDetails(attackTypeTemplate.repeatTime, projectiles), "splash": splashTemplate ? "\n" + g_Indent + g_Indent + splashDetails(splashTemplate) : "", "statusEffects": statusEffectsDetails })); } return sprintf(translate("%(label)s\n%(details)s"), { "label": headerFont(translate("Attack:")), "details": g_Indent + tooltips.join("\n" + g_Indent) }); } /** * @param applier - if true, return the tooltip for the Applier. If false, Receiver is returned. */ function getStatusEffectsTooltip(statusCode, template, applier) { let tooltipAttributes = []; let statusData = g_StatusEffectsMetadata.getData(statusCode); if (template.Damage || template.Capture) tooltipAttributes.push(attackEffectsDetails(template)); if (template.Interval) tooltipAttributes.push(attackRateDetails(+template.Interval)); if (template.Duration) tooltipAttributes.push(getStatusEffectDurationTooltip(template)); if (applier && statusData.applierTooltip) tooltipAttributes.push(translateWithContext("status effect", statusData.applierTooltip)); else if (!applier && statusData.receiverTooltip) tooltipAttributes.push(translateWithContext("status effect", statusData.receiverTooltip)); if (applier) return sprintf(translate("%(statusName)s: %(statusInfo)s %(stackability)s"), { "statusName": headerFont(translateWithContext("status effect", statusData.statusName)), "statusInfo": tooltipAttributes.join(commaFont(translate(", "))), "stackability": getStatusEffectStackabilityTooltip(template) }); return sprintf(translate("%(statusName)s: %(statusInfo)s"), { "statusName": headerFont(translateWithContext("status effect", statusData.statusName)), "statusInfo": tooltipAttributes.join(commaFont(translate(", "))) }); } function getStatusEffectDurationTooltip(template) { if (!template.Duration) return ""; return sprintf(translate("%(durName)s: %(duration)s"), { "durName": headerFont(translate("Duration")), "duration": getSecondsString((template._timeElapsed ? +template.Duration - template._timeElapsed : +template.Duration) / 1000) }); } function getStatusEffectStackabilityTooltip(template) { if (!template.Stackability || template.Stackability == "Ignore") return ""; let stackabilityString = ""; if (template.Stackability === "Extend") stackabilityString = translateWithContext("status effect stackability", "(extends)"); else if (template.Stackability === "Replace") stackabilityString = translateWithContext("status effect stackability", "(replaces)"); else if (template.Stackability === "Stack") stackabilityString = translateWithContext("status effect stackability", "(stacks)"); return sprintf(translate("%(stackability)s"), { "stackability": stackabilityString }); } function getGarrisonTooltip(template) { let tooltips = []; if (template.garrisonHolder) { tooltips.push ( sprintf(translate("%(label)s: %(garrisonLimit)s"), { "label": headerFont(translate("Garrison Limit")), "garrisonLimit": template.garrisonHolder.capacity }) ); if (template.garrisonHolder.buffHeal) tooltips.push( sprintf(translate("%(healRateLabel)s %(value)s %(health)s / %(second)s"), { "healRateLabel": headerFont(translate("Heal:")), "value": Math.round(template.garrisonHolder.buffHeal), "health": unitFont(translateWithContext("garrison tooltip", "Health")), "second": unitFont(translate("second")), }) ); tooltips.join(commaFont(translate(", "))); } if (template.garrisonable) { let extraSize; if (template.garrisonHolder) extraSize = template.garrisonHolder.occupiedSlots; if (template.garrisonable.size > 1 || extraSize) tooltips.push ( sprintf(translate("%(label)s: %(garrisonSize)s %(extraSize)s"), { "label": headerFont(translate("Garrison Size")), "garrisonSize": template.garrisonable.size, "extraSize": extraSize ? translateWithContext("nested garrison", "+ ") + extraSize : "" }) ); } return tooltips.join("\n"); } function getTurretsTooltip(template) { if (!template.turretHolder) return ""; return sprintf(translate("%(label)s: %(turretsLimit)s"), { "label": headerFont(translate("Turret Positions")), "turretsLimit": Object.keys(template.turretHolder.turretPoints).length }); } function getProjectilesTooltip(template) { if (!template.garrisonHolder || !template.buildingAI) return ""; let limit = Math.min( template.buildingAI.maxArrowCount || Infinity, template.buildingAI.defaultArrowCount + Math.round(template.buildingAI.garrisonArrowMultiplier * template.garrisonHolder.capacity) ); if (!limit) return ""; return [ sprintf(translate("%(label)s: %(value)s"), { "label": headerFont(translate("Projectile Limit")), "value": limit }), sprintf(translate("%(label)s: %(value)s"), { "label": headerFont(translateWithContext("projectiles", "Default")), "value": template.buildingAI.defaultArrowCount }), sprintf(translate("%(label)s: %(value)s"), { "label": headerFont(translateWithContext("projectiles", "Per Unit")), "value": +template.buildingAI.garrisonArrowMultiplier.toFixed(2) }) ].join(commaFont(translate(", "))); } function getRepairTimeTooltip(entState) { let result = []; result.push(sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Number of repairers:")), "details": entState.repairable.numBuilders })); if (entState.repairable.numBuilders) { result.push(sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Remaining repair time:")), "details": getSecondsString(Math.floor(entState.repairable.buildTime.timeRemaining)) })); let timeReduction = Math.round(entState.repairable.buildTime.timeRemaining - entState.repairable.buildTime.timeRemainingNew); result.push(sprintf(translatePlural( "Add another worker to speed up the repairs by %(second)s second.", "Add another worker to speed up the repairs by %(second)s seconds.", timeReduction), { "second": timeReduction })); } else result.push(sprintf(translatePlural( "Add a worker to finish the repairs in %(second)s second.", "Add a worker to finish the repairs in %(second)s seconds.", Math.round(entState.repairable.buildTime.timeRemainingNew)), { "second": Math.round(entState.repairable.buildTime.timeRemainingNew) })); return result.join("\n"); } function getBuildTimeTooltip(entState) { let result = []; result.push(sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Number of builders:")), "details": entState.foundation.numBuilders })); if (entState.foundation.numBuilders) { result.push(sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Remaining build time:")), "details": getSecondsString(Math.floor(entState.foundation.buildTime.timeRemaining)) })); let timeReduction = Math.round(entState.foundation.buildTime.timeRemaining - entState.foundation.buildTime.timeRemainingNew); result.push(sprintf(translatePlural( "Add another worker to speed up the construction by %(second)s second.", "Add another worker to speed up the construction by %(second)s seconds.", timeReduction), { "second": timeReduction })); } else result.push(sprintf(translatePlural( "Add a worker to finish the construction in %(second)s second.", "Add a worker to finish the construction in %(second)s seconds.", Math.round(entState.foundation.buildTime.timeRemainingNew)), { "second": Math.round(entState.foundation.buildTime.timeRemainingNew) })); return result.join("\n"); } /** * Multiplies the costs for a template by a given batch size. */ function multiplyEntityCosts(template, trainNum) { let totalCosts = {}; for (let r of getCostTypes()) if (template.cost[r]) totalCosts[r] = Math.floor(template.cost[r] * trainNum); return totalCosts; } /** * Helper function for getEntityCostTooltip. */ function getEntityCostComponentsTooltipString(template, entity, buildingsCountToTrainFullBatch = 1, fullBatchSize = 1, remainderBatch = 0) { if (!template.cost) return []; let totalCosts = multiplyEntityCosts(template, buildingsCountToTrainFullBatch * fullBatchSize + remainderBatch); if (template.cost.time) totalCosts.time = Math.ceil(template.cost.time * (entity ? Engine.GuiInterfaceCall("GetBatchTime", { "entity": entity, "batchSize": buildingsCountToTrainFullBatch > 0 ? fullBatchSize : remainderBatch }) : 1)); let costs = []; for (let type of getCostTypes()) if (totalCosts[type]) costs.push(sprintf(translate("%(component)s %(cost)s"), { "component": resourceIcon(type), "cost": totalCosts[type] })); return costs; } function getGatherTooltip(template) { if (!template.resourceGatherRates) return ""; let rates = {}; for (let resource of g_ResourceData.GetResources()) { let types = [resource.code]; for (let subtype in resource.subtypes) { // We ignore ruins as those are not that common if (subtype == "ruins") continue; let rate = template.resourceGatherRates[resource.code + "." + subtype]; if (rate > 0) rates[resource.code + "_" + subtype] = rate; } } if (!Object.keys(rates).length) return ""; return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Gather Rates:")), "details": Object.keys(rates).map( type => sprintf(translate("%(resourceIcon)s %(rate)s"), { "resourceIcon": resourceIcon(type), "rate": rates[type].toFixed(2) }) ).join(" ") }); } /** * Returns the resources this entity supplies in the specified entity's tooltip */ function getResourceSupplyTooltip(template) { if (!template.supply) return ""; let supply = template.supply; // Translation: Label in tooltip showing the resource type and quantity of a given resource supply. return sprintf(translate("%(label)s %(component)s %(amount)s"), { "label": headerFont(translate("Resource Supply:")), "component": resourceIcon(supply.type[0]), // Translation: Marks that a resource supply entity has an unending, infinite, supply of its resource. "amount": Number.isFinite(+supply.amount) ? supply.amount : translate("∞") }); } /** * @param {Object} template - The entity's template. * @return {string} - The resources this entity rewards to a collecter. */ function getTreasureTooltip(template) { if (!template.treasure) return ""; let resources = {}; for (let resource of g_ResourceData.GetResources()) { let type = resource.code; if (template.treasure.resources[type]) resources[type] = template.treasure.resources[type]; } let resourceNames = Object.keys(resources); if (!resourceNames.length) return ""; return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Reward:")), "details": resourceNames.map( type => sprintf(translate("%(resourceIcon)s %(reward)s"), { "resourceIcon": resourceIcon(type), "reward": resources[type] }) ).join(" ") }); } function getResourceTrickleTooltip(template) { if (!template.resourceTrickle) return ""; let resCodes = g_ResourceData.GetCodes().filter(res => !!template.resourceTrickle.rates[res]); if (!resCodes.length) return ""; return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Resource Trickle:")), "details": sprintf(translate("%(resources)s / %(time)s"), { "resources": resCodes.map( res => sprintf(translate("%(resourceIcon)s %(rate)s"), { "resourceIcon": resourceIcon(res), "rate": template.resourceTrickle.rates[res] }) ).join(" "), "time": getSecondsString(template.resourceTrickle.interval / 1000) }) }); } function getUpkeepTooltip(template) { if (!template.upkeep) return ""; let resCodes = g_ResourceData.GetCodes().filter(res => !!template.upkeep.rates[res]); if (!resCodes.length) return ""; return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Upkeep:")), "details": sprintf(translate("%(resources)s / %(time)s"), { "resources": resCodes.map( res => sprintf(translate("%(resourceIcon)s %(rate)s"), { "resourceIcon": resourceIcon(res), "rate": template.upkeep.rates[res] }) ).join(" "), "time": getSecondsString(template.upkeep.interval / 1000) }) }); } /** * Returns an array of strings for a set of wall pieces. If the pieces share * resource type requirements, output will be of the form '10 to 30 Stone', * otherwise output will be, e.g. '10 Stone, 20 Stone, 30 Stone'. */ function getWallPieceTooltip(wallTypes) { let out = []; let resourceCount = {}; for (let resource of getCostTypes()) if (wallTypes[0].cost[resource]) resourceCount[resource] = [wallTypes[0].cost[resource]]; let sameTypes = true; for (let i = 1; i < wallTypes.length; ++i) { for (let resource in wallTypes[i].cost) // Break out of the same-type mode if this wall requires // resource types that the first didn't. if (wallTypes[i].cost[resource] && !resourceCount[resource]) { sameTypes = false; break; } for (let resource in resourceCount) if (wallTypes[i].cost[resource]) resourceCount[resource].push(wallTypes[i].cost[resource]); else { sameTypes = false; break; } } if (sameTypes) for (let resource in resourceCount) // Translation: This string is part of the resources cost string on // the tooltip for wall structures. out.push(sprintf(translate("%(resourceIcon)s %(minimum)s to %(resourceIcon)s %(maximum)s"), { "resourceIcon": resourceIcon(resource), "minimum": Math.min.apply(Math, resourceCount[resource]), "maximum": Math.max.apply(Math, resourceCount[resource]) })); else for (let i = 0; i < wallTypes.length; ++i) out.push(getEntityCostComponentsTooltipString(wallTypes[i]).join(", ")); return out; } /** * Returns the cost information to display in the specified entity's construction button tooltip. */ function getEntityCostTooltip(template, player, entity, buildingsCountToTrainFullBatch, fullBatchSize, remainderBatch) { // Entities with a wallset component are proxies for initiating wall placement and as such do not have a cost of // their own; the individual wall pieces within it do. if (template.wallSet) { let templateLong = GetTemplateData(template.wallSet.templates.long, player); let templateMedium = GetTemplateData(template.wallSet.templates.medium, player); let templateShort = GetTemplateData(template.wallSet.templates.short, player); let templateTower = GetTemplateData(template.wallSet.templates.tower, player); let wallCosts = getWallPieceTooltip([templateShort, templateMedium, templateLong]); let towerCosts = getEntityCostComponentsTooltipString(templateTower); return sprintf(translate("Walls: %(costs)s"), { "costs": wallCosts.join(" ") }) + "\n" + sprintf(translate("Towers: %(costs)s"), { "costs": towerCosts.join(" ") }); } if (template.cost) { let costs = getEntityCostComponentsTooltipString(template, entity, buildingsCountToTrainFullBatch, fullBatchSize, remainderBatch).join(" "); if (costs) // Translation: Label in tooltip showing cost of a unit, structure or technology. return sprintf(translate("%(label)s %(costs)s"), { "label": headerFont(translate("Cost:")), "costs": costs }); } return ""; } function getRequiredTechnologyTooltip(technologyEnabled, requiredTechnology, civ) { if (technologyEnabled) return ""; return sprintf(translate("Requires %(technology)s"), { "technology": getEntityNames(GetTechnologyData(requiredTechnology, civ)) }); } /** * Returns the population bonus information to display in the specified entity's construction button tooltip. */ function getPopulationBonusTooltip(template) { if (!template.population || !template.population.bonus) return ""; return sprintf(translate("%(label)s %(bonus)s"), { "label": headerFont(translate("Population Bonus:")), "bonus": template.population.bonus }); } /** * Returns a message with the amount of each resource needed to create an entity. */ function getNeededResourcesTooltip(resources) { if (!resources) return ""; let formatted = []; for (let resource in resources) formatted.push(sprintf(translate("%(component)s %(cost)s"), { "component": '[font="sans-12"]' + resourceIcon(resource) + '[/font]', "cost": resources[resource] })); return coloredText( '[font="sans-bold-13"]' + translate("Insufficient resources:") + '[/font]', "red") + " " + formatted.join(" "); } function getSpeedTooltip(template) { if (!template.speed) return ""; - let walk = template.speed.walk.toFixed(1); - let run = template.speed.run.toFixed(1); + const walk = template.speed.walk.toFixed(1); + const run = template.speed.run.toFixed(1); if (walk == 0 && run == 0) return ""; + const acceleration = template.speed.acceleration.toFixed(1); return sprintf(translate("%(label)s %(speeds)s"), { "label": headerFont(translate("Speed:")), "speeds": sprintf(translate("%(speed)s %(movementType)s"), { "speed": walk, "movementType": unitFont(translate("Walk")) }) + commaFont(translate(", ")) + sprintf(translate("%(speed)s %(movementType)s"), { "speed": run, "movementType": unitFont(translate("Run")) + }) + + commaFont(translate(", ")) + + sprintf(translate("%(speed)s %(movementType)s"), { + "speed": acceleration, + "movementType": unitFont(translate("Acceleration")) }) }); } function getHealerTooltip(template) { if (!template.heal) return ""; let health = +(template.heal.health.toFixed(1)); let range = +(template.heal.range.toFixed(0)); let interval = +((template.heal.interval / 1000).toFixed(1)); return [ sprintf(translatePlural("%(label)s %(val)s %(unit)s", "%(label)s %(val)s %(unit)s", health), { "label": headerFont(translate("Heal:")), "val": health, "unit": unitFont(translatePlural("Health", "Health", health)) }), sprintf(translatePlural("%(label)s %(val)s %(unit)s", "%(label)s %(val)s %(unit)s", range), { "label": headerFont(translate("Range:")), "val": range, "unit": unitFont(translatePlural("meter", "meters", range)) }), sprintf(translatePlural("%(label)s %(val)s %(unit)s", "%(label)s %(val)s %(unit)s", interval), { "label": headerFont(translate("Interval:")), "val": interval, "unit": unitFont(translatePlural("second", "seconds", interval)) }) ].join(translate(", ")); } function getAurasTooltip(template) { let auras = template.auras || template.wallSet && GetTemplateData(template.wallSet.templates.long).auras; if (!auras) return ""; let tooltips = []; for (let auraID in auras) { let tooltip = sprintf(translate("%(auralabel)s %(aurainfo)s"), { "auralabel": headerFont(sprintf(translate("%(auraname)s:"), { "auraname": getEntityNames(auras[auraID]) })), "aurainfo": bodyFont(translate(auras[auraID].description)) }); let radius = +auras[auraID].radius; if (radius) tooltip += " " + sprintf(translatePlural("%(label)s %(val)s %(unit)s", "%(label)s %(val)s %(unit)s", radius), { "label": translateWithContext("aura", "Range:"), "val": radius, "unit": unitFont(translatePlural("meter", "meters", radius)) }); tooltips.push(tooltip); } return tooltips.join("\n"); } function getEntityNames(template) { if (!template.name.specific) return template.name.generic; if (template.name.specific == template.name.generic) return template.name.specific; let primaryName = g_SpecificNamesPrimary ? template.name.specific : template.name.generic; let secondaryName; if (g_ShowSecondaryNames) secondaryName = g_SpecificNamesPrimary ? template.name.generic : template.name.specific; if (secondaryName) return sprintf(translate("%(primaryName)s (%(secondaryName)s)"), { "primaryName": primaryName, "secondaryName": secondaryName }); return sprintf(translate("%(primaryName)s"), { "primaryName": primaryName }); } function getEntityNamesFormatted(template) { if (!template.name.specific) return setStringTags(template.name.generic, g_TooltipTextFormats.namePrimaryBig); let primaryName = g_SpecificNamesPrimary ? template.name.specific : template.name.generic; let secondaryName; if (g_ShowSecondaryNames) secondaryName = g_SpecificNamesPrimary ? template.name.generic : template.name.specific; if (!secondaryName || primaryName == secondaryName) return sprintf(translate("%(primaryName)s"), { "primaryName": setStringTags(primaryName[0], g_TooltipTextFormats.namePrimaryBig) + setStringTags(primaryName.slice(1).toUpperCase(), g_TooltipTextFormats.namePrimarySmall) }); // Translation: Example: "Epibátēs Athēnaîos [font="sans-bold-16"](Athenian Marine)[/font]" return sprintf(translate("%(primaryName)s (%(secondaryName)s)"), { "primaryName": setStringTags(primaryName[0], g_TooltipTextFormats.namePrimaryBig) + setStringTags(primaryName.slice(1).toUpperCase(), g_TooltipTextFormats.namePrimarySmall), "secondaryName": setStringTags(secondaryName, g_TooltipTextFormats.nameSecondary) }); } function getEntityPrimaryNameFormatted(template) { let primaryName = g_SpecificNamesPrimary ? template.name.specific : template.name.generic; if (!primaryName) return setStringTags(g_SpecificNamesPrimary ? template.name.generic : template.name.specific, g_TooltipTextFormats.namePrimaryBig); return setStringTags(primaryName[0], g_TooltipTextFormats.namePrimaryBig) + setStringTags(primaryName.slice(1).toUpperCase(), g_TooltipTextFormats.namePrimarySmall); } function getVisibleEntityClassesFormatted(template) { if (!template.visibleIdentityClasses || !template.visibleIdentityClasses.length) return ""; return headerFont(translate("Classes:")) + ' ' + bodyFont(template.visibleIdentityClasses.map(c => translate(c)).join(translate(", "))); } function getLootTooltip(template) { if (!template.loot && !template.resourceCarrying) return ""; let resourcesCarried = []; if (template.resourceCarrying) resourcesCarried = calculateCarriedResources( template.resourceCarrying, template.trader && template.trader.goods ); let lootLabels = []; for (let type of g_ResourceData.GetCodes().concat(["xp"])) { let loot = (template.loot && template.loot[type] || 0) + (resourcesCarried[type] || 0); if (!loot) continue; // Translation: %(component) will be the icon for the loot type and %(loot) will be the value. lootLabels.push(sprintf(translate("%(component)s %(loot)s"), { "component": resourceIcon(type), "loot": loot })); } if (!lootLabels.length) return ""; return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Loot:")), "details": lootLabels.join(" ") }); } function getResourceDropsiteTooltip(template) { if (!template || !template.resourceDropsite || !template.resourceDropsite.types) return ""; return sprintf(translate("%(label)s %(icons)s"), { "label": headerFont(translate("Dropsite for:")), "icons": template.resourceDropsite.types.map(type => resourceIcon(type)).join(" ") }); } function showTemplateViewerOnRightClickTooltip() { // Translation: Appears in a tooltip to indicate that right-clicking the corresponding GUI element will open the Template Details GUI page. return translate("Right-click to view more information."); } function showTemplateViewerOnClickTooltip() { // Translation: Appears in a tooltip to indicate that clicking the corresponding GUI element will open the Template Details GUI page. return translate("Click to view more information."); } /** * @param {number} number - A number to shorten using SI prefix. */ function abbreviateLargeNumbers(number) { if (number >= 1e6) return Math.floor(number / 1e6) + translateWithContext("One letter abbreviation for million", 'M'); if (number >= 1e5) return Math.floor(number / 1e3) + translateWithContext("One letter abbreviation for thousand", 'k'); if (number >= 1e4) return (number / 1e3).toFixed(1).replace(/\.0$/, '') + translateWithContext("One letter abbreviation for thousand", 'k'); return number; } Index: ps/trunk/binaries/data/mods/public/simulation/components/Formation.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/Formation.js (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/components/Formation.js (revision 25953) @@ -1,1035 +1,1040 @@ function Formation() {} Formation.prototype.Schema = "" + "" + "" + "" + "" + "" + "" + "" + ""+ "2"+ ""+ "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; // Distance at which we'll switch between column/box formations. var g_ColumnDistanceThreshold = 128; Formation.prototype.variablesToSerialize = [ "lastOrderVariant", "members", "memberPositions", "maxRowsUsed", "maxColumnsUsed", "finishedEntities", "idleEntities", "columnar", "rearrange", "formationMembersWithAura", "width", "depth", "twinFormations", "formationSeparation", "offsets" ]; Formation.prototype.Init = function(deserialized = false) { this.sortingClasses = this.template.SortingClasses.split(/\s+/g); this.shiftRows = this.template.ShiftRows == "true"; this.separationMultiplier = { "width": +this.template.UnitSeparationWidthMultiplier, "depth": +this.template.UnitSeparationDepthMultiplier }; this.sloppiness = +this.template.Sloppiness; 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); if (this.template.AnimationVariants) { this.animationvariants = []; let differentAnimationVariants = this.template.AnimationVariants.split(/\s*;\s*/); // Loop over the different rectangulars that will map to different animation variants. for (let rectAnimationVariant of differentAnimationVariants) { let rect, replacementAnimationVariant; [rect, replacementAnimationVariant] = rectAnimationVariant.split(/\s*:\s*/); let rows, columns; [rows, columns] = rect.split(/\s*,\s*/); let minRow, maxRow, minColumn, maxColumn; [minRow, maxRow] = rows.split(/\s*\.\.\s*/); [minColumn, maxColumn] = columns.split(/\s*\.\.\s*/); this.animationvariants.push({ "minRow": +minRow, "maxRow": +maxRow, "minColumn": +minColumn, "maxColumn": +maxColumn, "name": replacementAnimationVariant }); } } this.lastOrderVariant = undefined; // Entity IDs currently belonging to this formation. this.members = []; this.memberPositions = {}; this.maxRowsUsed = 0; this.maxColumnsUsed = []; // Entities that have finished the original task. this.finishedEntities = new Set(); this.idleEntities = new Set(); // Whether we're travelling in column (vs box) formation. this.columnar = false; // Whether we should rearrange all formation members. this.rearrange = true; // Members with a formation aura. this.formationMembersWithAura = []; this.width = 0; this.depth = 0; this.twinFormations = []; // Distance from which two twin formations will merge into one. this.formationSeparation = 0; if (deserialized) return; Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer) .SetInterval(this.entity, IID_Formation, "ShapeUpdate", 1000, 1000, null); }; Formation.prototype.Serialize = function() { let result = {}; for (let key of this.variablesToSerialize) result[key] = this[key]; return result; }; Formation.prototype.Deserialize = function(data) { this.Init(true); for (let key in data) this[key] = data[key]; }; /** * 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) { let cmpEntPosition = Engine.QueryInterface(ent, IID_Position); if (!cmpEntPosition || !cmpEntPosition.IsInWorld()) return INVALID_ENTITY; let entPosition = cmpEntPosition.GetPosition2D(); let closestMember = INVALID_ENTITY; let closestDistance = Infinity; for (let member of this.members) { if (filter && !filter(ent)) continue; let cmpPosition = Engine.QueryInterface(member, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) continue; let pos = cmpPosition.GetPosition2D(); let 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 variant for a certain member of this formation. * @param entity The entity ID to get the animation for. * @return The name of the animation variant as defined in the template, * e.g. "testudo_front" or undefined if does not exist. */ Formation.prototype.GetFormationAnimationVariant = function(entity) { if (!this.animationvariants || !this.animationvariants.length || this.columnar || !this.memberPositions[entity]) return undefined; let row = this.memberPositions[entity].row; let column = this.memberPositions[entity].column; for (let i = 0; i < this.animationvariants.length; ++i) { let minRow = this.animationvariants[i].minRow; if (minRow < 0) minRow += this.maxRowsUsed + 1; if (row < minRow) continue; let maxRow = this.animationvariants[i].maxRow; if (maxRow < 0) maxRow += this.maxRowsUsed + 1; if (row > maxRow) continue; let minColumn = this.animationvariants[i].minColumn; if (minColumn < 0) minColumn += this.maxColumnsUsed[row] + 1; if (column < minColumn) continue; let maxColumn = this.animationvariants[i].maxColumn; if (maxColumn < 0) maxColumn += this.maxColumnsUsed[row] + 1; if (column > maxColumn) continue; return this.animationvariants[i].name; } return undefined; }; Formation.prototype.SetFinishedEntity = function(ent) { // Rotate the entity to the correct angle. const cmpPosition = Engine.QueryInterface(this.entity, IID_Position); const cmpEntPosition = Engine.QueryInterface(ent, IID_Position); if (cmpEntPosition && cmpEntPosition.IsInWorld() && cmpPosition && cmpPosition.IsInWorld()) cmpEntPosition.TurnTo(cmpPosition.GetRotation().y); this.finishedEntities.add(ent); }; Formation.prototype.UnsetFinishedEntity = function(ent) { this.finishedEntities.delete(ent); }; Formation.prototype.ResetFinishedEntities = function() { this.finishedEntities.clear(); }; Formation.prototype.AreAllMembersFinished = function() { return this.finishedEntities.size === this.members.length; }; Formation.prototype.SetIdleEntity = function(ent) { this.idleEntities.add(ent); }; Formation.prototype.UnsetIdleEntity = function(ent) { this.idleEntities.delete(ent); }; Formation.prototype.ResetIdleEntities = function() { this.idleEntities.clear(); }; Formation.prototype.AreAllMembersIdle = function() { return this.idleEntities.size === this.members.length; }; /** * Set whether we are allowed to rearrange formation members. */ Formation.prototype.SetRearrange = function(rearrange) { this.rearrange = rearrange; }; /** * Initialize the members of this formation. * Must only be called once. * All members must implement UnitAI. */ Formation.prototype.SetMembers = function(ents) { this.members = ents; for (let ent of this.members) { 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.ApplyFormationAura(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. * @param {boolean} rename - Whether the removal was part of an entity rename (prevents disbanding of the formation when under the member limit). */ Formation.prototype.RemoveMembers = function(ents, renamed = false) { this.offsets = undefined; this.members = this.members.filter(ent => ents.indexOf(ent) === -1); for (let ent of ents) { this.finishedEntities.delete(ent); let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); cmpUnitAI.UpdateWorkOrders(); cmpUnitAI.SetFormationController(INVALID_ENTITY); } for (let ent of this.formationMembersWithAura) { let cmpAuras = Engine.QueryInterface(ent, IID_Auras); cmpAuras.RemoveFormationAura(ents); // The unit with the aura is also removed from the formation. if (ents.indexOf(ent) !== -1) cmpAuras.RemoveFormationAura(this.members); } this.formationMembersWithAura = this.formationMembersWithAura.filter(function(e) { return ents.indexOf(e) == -1; }); // If there's nobody left, destroy the formation // unless this is a rename where we can have 0 members temporarily. if (this.members.length < +this.template.RequiredMemberCount && !renamed) { this.Disband(); return; } this.ComputeMotionParameters(); if (!this.rearrange) return; // Rearrange the remaining members. this.MoveMembersIntoFormation(true, true, this.lastOrderVariant); }; Formation.prototype.AddMembers = function(ents) { this.offsets = undefined; for (let ent of this.formationMembersWithAura) { let cmpAuras = Engine.QueryInterface(ent, IID_Auras); cmpAuras.ApplyFormationAura(ents); } this.members = this.members.concat(ents); for (let ent of ents) { let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); cmpUnitAI.SetFormationController(this.entity); if (!cmpUnitAI.GetOrders().length) cmpUnitAI.SetNextState("FORMATIONMEMBER.IDLE"); let cmpAuras = Engine.QueryInterface(ent, IID_Auras); if (cmpAuras && cmpAuras.HasFormationAura()) { this.formationMembersWithAura.push(ent); cmpAuras.ApplyFormationAura(this.members); } } this.ComputeMotionParameters(); if (!this.rearrange) return; this.MoveMembersIntoFormation(true, true, this.lastOrderVariant); }; /** * Remove all members and destroy the formation. */ Formation.prototype.Disband = function() { for (let ent of this.members) { let cmpUnitAI = Engine.QueryInterface(ent, IID_UnitAI); cmpUnitAI.SetFormationController(INVALID_ENTITY); } for (let ent of this.formationMembersWithAura) { let cmpAuras = Engine.QueryInterface(ent, IID_Auras); cmpAuras.RemoveFormationAura(this.members); } this.members = []; this.finishedEntities.clear(); this.formationMembersWithAura = []; this.offsets = undefined; let cmpUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI); // Hack: switch to a clean state to stop timers. cmpUnitAI.UnitFsm.SwitchToNextState(cmpUnitAI, ""); Engine.DestroyEntity(this.entity); }; /** * Set all members to form up into the formation shape. * @param {boolean} moveCenter - The formation center will be reinitialized * to the center of the units. * @param {boolean} force - All individual orders of the formation units are replaced, * otherwise the order to walk into formation is just pushed to the front. * @param {string | undefined} variant - Variant to be passed as order parameter. */ Formation.prototype.MoveMembersIntoFormation = function(moveCenter, force, variant) { if (!this.members.length) return; let active = []; let positions = []; let rotations = 0; for (let ent of this.members) { let cmpPosition = Engine.QueryInterface(ent, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) continue; active.push(ent); // Query the 2D position as the exact height calculation isn't needed, // but bring the position to the correct coordinates. positions.push(cmpPosition.GetPosition2D()); rotations += cmpPosition.GetRotation().y; } let avgpos = Vector2D.average(positions); let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); // Reposition the formation if we're told to or if we don't already have a position. if (moveCenter || (cmpPosition && !cmpPosition.IsInWorld())) this.SetupPositionAndHandleRotation(avgpos.x, avgpos.y, rotations / active.length); this.lastOrderVariant = variant; // Switch between column and box if necessary. let cmpFormationUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI); let walkingDistance = cmpFormationUnitAI.ComputeWalkingDistance(); let columnar = walkingDistance > g_ColumnDistanceThreshold; if (columnar != this.columnar) { this.columnar = columnar; this.offsets = undefined; } let offsetsChanged = false; let newOrientation = this.GetEstimatedOrientation(avgpos); if (!this.offsets) { this.offsets = this.ComputeFormationOffsets(active, positions); offsetsChanged = true; } let xMax = 0; let yMax = 0; let xMin = 0; let yMin = 0; if (force) // Reset finishedEntities as FormationWalk is called. this.ResetFinishedEntities(); for (let i = 0; i < this.offsets.length; ++i) { let offset = this.offsets[i]; let cmpUnitAI = Engine.QueryInterface(offset.ent, IID_UnitAI); if (!cmpUnitAI) { warn("Entities without UnitAI in formation are not supported."); continue; } let data = { "target": this.entity, "x": offset.x, "z": offset.y, "offsetsChanged": offsetsChanged, "variant": variant }; 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() { let positions = []; let rotations = 0; for (let ent of this.members) { let cmpPosition = Engine.QueryInterface(ent, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) continue; positions.push(cmpPosition.GetPosition2D()); rotations += cmpPosition.GetRotation().y; } let avgpos = Vector2D.average(positions); this.SetupPositionAndHandleRotation(avgpos.x, avgpos.y, rotations / positions.length); }; /** * Set formation position. * If formation is not in world at time this is called, set new rotation and flag for range manager. */ Formation.prototype.SetupPositionAndHandleRotation = function(x, y, rot) { let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition) return; let wasInWorld = cmpPosition.IsInWorld(); cmpPosition.JumpTo(x, y); if (wasInWorld) return; let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); cmpRangeManager.SetEntityFlag(this.entity, "normal", false); cmpPosition.TurnTo(rot); }; Formation.prototype.GetAvgFootprint = function(active) { let footprints = []; for (let ent of active) { let cmpFootprint = Engine.QueryInterface(ent, IID_Footprint); if (cmpFootprint) footprints.push(cmpFootprint.GetShape()); } if (!footprints.length) return { "width": 1, "depth": 1 }; let r = { "width": 0, "depth": 0 }; for (let 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) { let separation = this.GetAvgFootprint(active); separation.width *= this.separationMultiplier.width; separation.depth *= this.separationMultiplier.depth; let sortingClasses; if (this.columnar) sortingClasses = ["Cavalry", "Infantry"]; else 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. let types = {}; for (let i = 0; i < sortingClasses.length; ++i) types[sortingClasses[i]] = []; for (let i in active) { let cmpIdentity = Engine.QueryInterface(active[i], IID_Identity); let classes = cmpIdentity.GetClassesList(); let done = false; for (let 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] }); } let count = active.length; let shape = this.template.FormationShape; let shiftRows = this.shiftRows; let centerGap = this.centerGap; let sortingOrder = this.template.SortingOrder; let offsets = []; // Choose a sensible size/shape for the various formations, depending on number of units. let cols; if (this.columnar) { shape = "square"; cols = Math.min(count, 3); shiftRows = false; centerGap = 0; sortingOrder = null; } else { let 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") { let width = Math.sqrt(count) * (separation.width + separation.depth) * 2.5; for (let i = 0; i < count; ++i) { let 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 = []; let r = 0; let left = count; // While there are units left, start a new row in the formation. while (left > 0) { // Save the position of the row. let z = -r * separation.depth; // Alternate between the left and right side of the center to have a symmetrical distribution. let side = 1; let n; // Determine the number of entities in this row of the formation. if (shape == "square") { n = cols; if (shiftRows) n -= r % 2; } else if (shape == "triangle") { if (shiftRows) n = r + 1; else n = r * 2 + 1; } if (!shiftRows && n > left) n = left; for (let c = 0; c < n && left > 0; ++c) { // Switch sides for the next entity. side *= -1; let x; if (n % 2 == 0) x = side * (Math.floor(c / 2) + 0.5) * separation.width; else x = side * Math.ceil(c / 2) * separation.width; if (centerGap) { // Don't use the center position with a center gap. if (x == 0) continue; x += side * centerGap / 2; } let column = Math.ceil(n / 2) + Math.ceil(c / 2) * side; let r1 = randFloat(-1, 1) * this.sloppiness; let r2 = randFloat(-1, 1) * this.sloppiness; 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. let 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 (sortingOrder == "fillFromTheSides") offsets.sort(function(o1, o2) { return Math.abs(o1.x) < Math.abs(o2.x);}); else if (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. let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); let formationPos = cmpPosition.GetPosition2D(); // Use realistic place assignment, // every soldier searches the closest available place in the formation. let newOffsets = []; let realPositions = this.GetRealOffsetPositions(offsets, formationPos); for (let i = sortingClasses.length; i; --i) { let t = types[sortingClasses[i - 1]]; if (!t.length) continue; let usedOffsets = offsets.splice(-t.length); let usedRealPositions = realPositions.splice(-t.length); for (let entPos of t) { let 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 entPos - Object with entity position and entity ID. * @param realPositions - The world coordinates of the available offsets. * @param offsets * @return The index of the closest offset position. */ Formation.prototype.TakeClosestOffset = function(entPos, realPositions, offsets) { let pos = entPos.pos; let closestOffsetId = -1; let offsetDistanceSq = Infinity; for (let i = 0; i < realPositions.length; ++i) { let 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) { let offsetPositions = []; let { sin, cos } = this.GetEstimatedOrientation(pos); // Calculate the world positions. for (let 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 current rotation. * Return the sine and cosine of the angle. */ Formation.prototype.GetEstimatedOrientation = function(pos) { let r = {}; let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition) return r; let 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() { let maxRadius = 0; let minSpeed = Infinity; + let minAcceleration = Infinity; for (let ent of this.members) { let cmpUnitMotion = Engine.QueryInterface(ent, IID_UnitMotion); if (cmpUnitMotion) + { minSpeed = Math.min(minSpeed, cmpUnitMotion.GetWalkSpeed()); + minAcceleration = Math.min(minAcceleration, cmpUnitMotion.GetAcceleration()); + } } minSpeed *= this.GetSpeedMultiplier(); let cmpUnitMotion = Engine.QueryInterface(this.entity, IID_UnitMotion); cmpUnitMotion.SetSpeedMultiplier(minSpeed / cmpUnitMotion.GetWalkSpeed()); + cmpUnitMotion.SetAcceleration(minAcceleration); }; Formation.prototype.ShapeUpdate = function() { if (!this.rearrange) return; // Check the distance to twin formations, and merge if // the formations could collide. for (let i = this.twinFormations.length - 1; i >= 0; --i) { // Only do the check on one side. if (this.twinFormations[i] <= this.entity) continue; let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); let cmpOtherPosition = Engine.QueryInterface(this.twinFormations[i], IID_Position); let cmpOtherFormation = Engine.QueryInterface(this.twinFormations[i], IID_Formation); if (!cmpPosition || !cmpOtherPosition || !cmpOtherFormation || !cmpPosition.IsInWorld() || !cmpOtherPosition.IsInWorld()) continue; let thisPosition = cmpPosition.GetPosition2D(); let otherPosition = cmpOtherPosition.GetPosition2D(); let dx = thisPosition.x - otherPosition.x; let dy = thisPosition.y - otherPosition.y; let dist = Math.sqrt(dx * dx + dy * dy); let thisSize = this.GetSize(); let otherSize = cmpOtherFormation.GetSize(); let 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. let cmpUnitAI = Engine.QueryInterface(this.entity, IID_UnitAI); let walkingDistance = cmpUnitAI.ComputeWalkingDistance(); let columnar = walkingDistance > g_ColumnDistanceThreshold; if (columnar != this.columnar) { this.offsets = undefined; this.columnar = columnar; // Disable moveCenter so we can't get stuck in a loop of switching // shape causing center to change causing shape to switch back. this.MoveMembersIntoFormation(false, true, this.lastOrderVariant); } }; Formation.prototype.ResetOrderVariant = function() { this.lastOrderVariant = undefined; }; 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) return; if (this.finishedEntities.delete(msg.entity)) this.finishedEntities.add(msg.newentity); // Save rearranging to temporarily set it to false. let temp = this.rearrange; this.rearrange = false; // First remove the old member to be able to reuse its position. this.RemoveMembers([msg.entity], true); this.AddMembers([msg.newentity]); this.memberPositions[msg.newentity] = this.memberPositions[msg.entity]; this.rearrange = temp; }; Formation.prototype.RegisterTwinFormation = function(entity) { let cmpFormation = Engine.QueryInterface(entity, IID_Formation); if (!cmpFormation) return; this.twinFormations.push(entity); cmpFormation.twinFormations.push(this.entity); }; Formation.prototype.DeleteTwinFormations = function() { for (let ent of this.twinFormations) { let cmpFormation = Engine.QueryInterface(ent, IID_Formation); if (cmpFormation) cmpFormation.twinFormations.splice(cmpFormation.twinFormations.indexOf(this.entity), 1); } this.twinFormations = []; }; Formation.prototype.LoadFormation = function(newTemplate) { const newFormation = ChangeEntityTemplate(this.entity, newTemplate); return Engine.QueryInterface(newFormation, IID_UnitAI); }; Formation.prototype.OnEntityRenamed = function(msg) { const members = clone(this.members); this.Disband(); Engine.QueryInterface(msg.newentity, IID_Formation).SetMembers(members); }; 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 25952) +++ ps/trunk/binaries/data/mods/public/simulation/components/GuiInterface.js (revision 25953) @@ -1,2153 +1,2154 @@ function GuiInterface() {} GuiInterface.prototype.Schema = ""; GuiInterface.prototype.Serialize = function() { // This component isn't network-synchronized 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 = {}; this.selectionDirty = {}; this.obstructionSnap = new ObstructionSnap(); }; /* * 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 cmpPlayerManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager); let numPlayers = cmpPlayerManager.GetNumPlayers(); for (let i = 0; i < numPlayers; ++i) { let cmpPlayer = QueryPlayerIDInterface(i); let cmpPlayerEntityLimits = QueryPlayerIDInterface(i, IID_EntityLimits); // Work out which 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"; } 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(), "resourceGatherers": cmpPlayer.GetResourceGatherers(), "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, "matchEntityCounts": cmpPlayerEntityLimits ? cmpPlayerEntityLimits.GetMatchCounts() : 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": cmpPlayer.CanBarter(), "barterPrices": Engine.QueryInterface(SYSTEM_ENTITY, IID_Barter).GetPrices(cmpPlayer) }); } 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(); let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); ret.timeElapsed = cmpTimer.GetTime(); 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; } let cmpCinemaManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_CinemaManager); if (cmpCinemaManager) ret.cinemaPlaying = cmpCinemaManager.IsPlaying(); let cmpEndGameManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_EndGameManager); ret.victoryConditions = cmpEndGameManager.GetVictoryConditions(); ret.alliedVictory = cmpEndGameManager.GetAlliedVictory(); ret.maxWorldPopulation = cmpPlayerManager.GetMaxWorldPopulation(); 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() { let ret = this.GetSimulationState(); 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; }; /** * Returns the gamesettings that were chosen at the time the match started. */ GuiInterface.prototype.GetInitAttributes = function() { return InitAttributes; }; /** * This data will be stored in the replay metadata file after a match has been finished recording. */ GuiInterface.prototype.GetReplayMetadata = function() { let extendedSimState = this.GetExtendedSimulationState(); return { "timeElapsed": extendedSimState.timeElapsed, "playerStates": extendedSimState.players, "mapSettings": InitAttributes.settings }; }; /** * Called when the game ends if the current game is part of a campaign run. */ GuiInterface.prototype.GetCampaignGameEndData = function(player) { let cmpTrigger = Engine.QueryInterface(SYSTEM_ENTITY, IID_Trigger); if (Trigger.prototype.OnCampaignGameEnd) return Trigger.prototype.OnCampaignGameEnd(); return {}; }; 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); if (!ent) return null; // 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(), "selectionGroupName": cmpIdentity.GetSelectionGroupName(), "canDelete": !cmpIdentity.IsUndeletable(), "hasSomeFormation": cmpIdentity.HasSomeFormation(), "formations": cmpIdentity.GetFormationsList(), "controllable": cmpIdentity.IsControllable() }; const cmpFormation = Engine.QueryInterface(ent, IID_Formation); if (cmpFormation) ret.formation = { "members": cmpFormation.GetMembers() }; 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.IsInjured(); 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() }; let cmpPopulation = Engine.QueryInterface(ent, IID_Population); if (cmpPopulation) ret.population = { "bonus": cmpPopulation.GetPopBonus() }; let cmpUpgrade = Engine.QueryInterface(ent, IID_Upgrade); if (cmpUpgrade) ret.upgrade = { "upgrades": cmpUpgrade.GetUpgrades(), "progress": cmpUpgrade.GetProgress(), "template": cmpUpgrade.GetUpgradingTo(), "isUpgrading": cmpUpgrade.IsUpgrading() }; let cmpStatusEffects = Engine.QueryInterface(ent, IID_StatusEffectsReceiver); if (cmpStatusEffects) ret.statusEffects = cmpStatusEffects.GetActiveStatuses(); let cmpProductionQueue = Engine.QueryInterface(ent, IID_ProductionQueue); if (cmpProductionQueue) ret.production = { "entities": cmpProductionQueue.GetEntitiesList(), "technologies": cmpProductionQueue.GetTechnologiesList(), "techCostMultiplier": cmpProductionQueue.GetTechCostMultiplier(), "queue": cmpProductionQueue.GetQueue(), "autoqueue": cmpProductionQueue.IsAutoQueueing() }; 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(), "occupiedSlots": cmpGarrisonHolder.OccupiedSlots() }; let cmpTurretHolder = Engine.QueryInterface(ent, IID_TurretHolder); if (cmpTurretHolder) ret.turretHolder = { "turretPoints": cmpTurretHolder.GetTurretPoints() }; let cmpTurretable = Engine.QueryInterface(ent, IID_Turretable); if (cmpTurretable) ret.turretable = { "ejectable": cmpTurretable.IsEjectable(), "holder": cmpTurretable.HolderID() }; let cmpGarrisonable = Engine.QueryInterface(ent, IID_Garrisonable); if (cmpGarrisonable) ret.garrisonable = { "holder": cmpGarrisonable.HolderID(), "size": cmpGarrisonable.UnitSize() }; 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(), "formation": cmpUnitAI.GetFormationController() }; 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] = {}; Object.assign(ret.attack[type], cmpAttack.GetAttackEffectsData(type)); ret.attack[type].attackName = cmpAttack.GetAttackName(type); ret.attack[type].splash = cmpAttack.GetSplashData(type); if (ret.attack[type].splash) Object.assign(ret.attack[type].splash, cmpAttack.GetAttackEffectsData(type, true)); 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 (cmpPosition && cmpPosition.IsInWorld()) // For units, take the range in front of it, no spread, so angle = 0, // else, take the average elevation around it: angle = 2 * pi. ret.attack[type].elevationAdaptedRange = cmpRangeManager.GetElevationAdaptedRange(cmpPosition.GetPosition(), cmpPosition.GetRotation(), range.max, range.elevationBonus, cmpUnitAI ? 0 : 2 * Math.PI); else // Not in world, set a default? ret.attack[type].elevationAdaptedRange = ret.attack.maxRange; } } let cmpResistance = QueryMiragedInterface(ent, IID_Resistance); if (cmpResistance) ret.resistance = cmpResistance.GetResistanceOfForm(cmpFoundation ? "Foundation" : "Entity"); 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("Barter")) ret.isBarterMarket = true; let cmpHeal = Engine.QueryInterface(ent, IID_Heal); if (cmpHeal) ret.heal = { "health": cmpHeal.GetHealth(), "range": cmpHeal.GetRange().max, "interval": cmpHeal.GetInterval(), "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.GetInterval(), "rates": cmpResourceTrickle.GetRates() }; let cmpTreasure = Engine.QueryInterface(ent, IID_Treasure); if (cmpTreasure) ret.treasure = { "collectTime": cmpTreasure.CollectionTime(), "resources": cmpTreasure.Resources() }; let cmpTreasureCollector = Engine.QueryInterface(ent, IID_TreasureCollector); if (cmpTreasureCollector) ret.treasureCollector = true; let cmpUnitMotion = Engine.QueryInterface(ent, IID_UnitMotion); if (cmpUnitMotion) ret.speed = { "walk": cmpUnitMotion.GetWalkSpeed(), - "run": cmpUnitMotion.GetWalkSpeed() * cmpUnitMotion.GetRunMultiplier() + "run": cmpUnitMotion.GetWalkSpeed() * cmpUnitMotion.GetRunMultiplier(), + "acceleration": cmpUnitMotion.GetAcceleration() }; let cmpUpkeep = Engine.QueryInterface(ent, IID_Upkeep); if (cmpUpkeep) ret.upkeep = { "interval": cmpUpkeep.GetInterval(), "rates": cmpUpkeep.GetRates() }; 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, data) { let templateName = data.templateName; let owner = data.player !== undefined ? data.player : player; 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, owner, aurasTemplate); let auraNames = template.Auras._string.split(/\s+/); for (let name of auraNames) { let auraTemplate = AuraTemplates.Get(name); if (!auraTemplate) error("Template " + templateName + " has undefined aura " + name); else aurasTemplate[name] = auraTemplate; } return GetTemplateDataHelper(template, owner, aurasTemplate); }; GuiInterface.prototype.IsTechnologyResearched = function(player, data) { if (!data.tech) return true; let cmpTechnologyManager = QueryPlayerIDInterface(data.player !== undefined ? 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 !== undefined ? 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) { let cmpAttackDetection = QueryPlayerIDInterface(player, IID_AttackDetection); if (!cmpAttackDetection) return []; return cmpAttackDetection.GetIncomingAttacks(); }; /** * Used to show a red square over GUI elements you can't yet afford. */ GuiInterface.prototype.GetNeededResources = function(player, data) { let cmpPlayer = QueryPlayerIDInterface(data.player !== undefined ? data.player : player); return cmpPlayer ? cmpPlayer.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; this.selectionDirty[msg.player] = true; }; GuiInterface.prototype.IsTemplateModified = function(player) { return this.templateModified[player] || false; }; GuiInterface.prototype.ResetTemplateModified = function() { this.templateModified = {}; }; /** * Some changes may require an update to the selection panel, * which is cached for efficiency. Inform the GUI it needs reloading. */ GuiInterface.prototype.OnDisabledTemplatesChanged = function(msg) { this.selectionDirty[msg.player] = true; }; GuiInterface.prototype.OnDisabledTechnologiesChanged = function(msg) { this.selectionDirty[msg.player] = true; }; GuiInterface.prototype.SetSelectionDirty = function(player) { this.selectionDirty[player] = true; }; GuiInterface.prototype.IsSelectionDirty = function(player) { return this.selectionDirty[player] || false; }; GuiInterface.prototype.ResetSelectionDirty = function() { this.selectionDirty = {}; }; /** * 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) { let cmpPlayer = QueryPlayerIDInterface(wantedPlayer); if (!cmpPlayer) return []; return cmpPlayer.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) { // Cache of owner -> color map let playerColors = {}; 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 // May return undefined if no rally point is set. pos = cmpRallyPoint.GetPositions()[0]; if (pos) { // Only update the position if we changed it (cmd.queued is set). // Note that Add-/SetPosition take a CFixedVector2D which has X/Y components, not X/Z. if ("queued" in cmd) { if (cmd.queued == true) cmpRallyPointRenderer.AddPosition(new Vector2D(pos.x, pos.z)); else cmpRallyPointRenderer.SetPosition(new Vector2D(pos.x, pos.z)); } else if (!cmpRallyPointRenderer.IsSet()) // Rebuild the renderer when not set (when reading saved game or in case of building update). for (let posi of cmpRallyPoint.GetPositions()) cmpRallyPointRenderer.AddPosition(new Vector2D(posi.x, 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 cmpOwnership = Engine.QueryInterface(ent, IID_Ownership); if (cmpOwnership) cmpOwnership.SetOwner(cmd.owner); 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": [] }; if (!this.placementEntity || this.placementEntity[0] != cmd.template) { if (this.placementEntity) Engine.DestroyEntity(this.placementEntity[1]); if (cmd.template == "") this.placementEntity = undefined; else this.placementEntity = [cmd.template, Engine.AddLocalEntity("preview|" + cmd.template)]; } if (this.placementEntity) { let ent = this.placementEntity[1]; 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); 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': ..., * } * } * * @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; // Did the start position snap to anything? // If we snapped, was it to an entity? If yes, hold that entity's ID. let start = { "pos": cmd.start, "angle": 0, "snapped": false, "snappedEnt": INVALID_ENTITY }; // Did the end position snap to anything? // If we snapped, was it to an entity? If yes, hold that entity's ID. let end = { "pos": cmd.end, "angle": 0, "snapped": false, "snappedEnt": INVALID_ENTITY }; // -------------------------------------------------------------------------------- // 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; } 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, { "templateName": tpl }), }; 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) { // Value of 0.5 was determined through trial and error. let snapRadius = this.placementWallEntities[wallSet.templates.tower].templateData.wallPiece.length * 0.5; 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, "time": 0 } }; for (let res of Resources.GetCodes()) result.cost[res] = 0; let previewEntities = []; if (end.pos) // See helpers/Walls.js. previewEntities = GetWallPlacement(this.placementWallEntities, wallSet, start, end); // 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 && 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 ? 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 ? 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; // Number of entities that are required to build the entire wall, regardless of validity. let numRequiredPieces = 0; 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) { ent = Engine.AddLocalEntity("preview|" + tpl); entPool.entities.push(ent); } else 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); 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", "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 (data.snapToEdges) { let position = this.obstructionSnap.getPosition(data, template); if (position) return position; } 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.PlaySoundForPlayer = function(player, data) { let playerEntityID = Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetPlayerByID(player); let cmpSound = Engine.QueryInterface(playerEntityID, IID_Sound); if (!cmpSound) return; let soundGroup = cmpSound.GetSoundGroup(data.name); if (soundGroup) Engine.QueryInterface(SYSTEM_ENTITY, IID_SoundManager).PlaySoundGroupForPlayer(soundGroup, player); }; 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. let 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()) return { "idle": false }; let cmpGarrisonable = Engine.QueryInterface(unit, IID_Garrisonable); if (cmpGarrisonable && cmpGarrisonable.IsGarrisoned()) return { "idle": false }; const cmpTurretable = Engine.QueryInterface(unit, IID_Turretable); if (cmpTurretable && cmpTurretable.IsTurreted()) 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; let cmpMarket = QueryMiragedInterface(data.firstMarket, IID_Market); return cmpMarket && cmpMarket.CalculateTraderGain(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 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) { let cmpPlayer = QueryPlayerIDInterface(player); if (!cmpPlayer) return []; return cmpPlayer.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, "GetInitAttributes": 1, "GetReplayMetadata": 1, "GetCampaignGameEndData": 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, "PlaySoundForPlayer": 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, "IsSelectionDirty": 1, "ResetSelectionDirty": 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/UnitMotionFlying.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/UnitMotionFlying.js (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/components/UnitMotionFlying.js (revision 25953) @@ -1,380 +1,390 @@ // (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) { let turnLength = msg.turnLength; if (!this.hasTarget) return; let cmpGarrisonHolder = Engine.QueryInterface(this.entity, IID_GarrisonHolder); let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); let pos = cmpPosition.GetPosition(); let angle = cmpPosition.GetRotation().y; let cmpTerrain = Engine.QueryInterface(SYSTEM_ENTITY, IID_Terrain); let cmpWaterManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_WaterManager); let ground = Math.max(cmpTerrain.GetGroundLevel(pos.x, pos.z), cmpWaterManager.GetWaterLevel(pos.x, pos.z)); let newangle = angle; let canTurn = true; let distanceToTargetSquared = Math.euclidDistance2DSquared(pos.x, pos.z, this.targetX, this.targetZ); 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) { let cmpHealth = Engine.QueryInterface(this.entity, IID_Health); 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. let terrainSize = cmpTerrain.GetMapSize(); let cmpRangeManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_RangeManager); if (cmpRangeManager.GetLosCircular()) { let mapRadius = terrainSize/2; let x = pos.x - mapRadius; let z = pos.z - mapRadius; let 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; distanceToTargetSquared = Math.euclidDistance2DSquared(pos.x, pos.z, this.targetX, this.targetZ); } } 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; distanceToTargetSquared = Math.euclidDistance2DSquared(pos.x, pos.z, this.targetX, this.targetZ); } } } 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; let 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; let 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 (this.template.StationaryDistance && distanceToTargetSquared <= +this.template.StationaryDistance * +this.template.StationaryDistance) { cmpPosition.SetXZRotation(0, 0); this.pitch = 0; this.roll = 0; this.reachedTarget = true; cmpPosition.TurnTo(Math.atan2(this.targetX - pos.x, this.targetZ - pos.z)); Engine.PostMessage(this.entity, MT_MotionUpdate, { "updateString": "likelySuccess" }); return; } // 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); let 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) if (!this.reachedTarget && this.targetMinRange * this.targetMinRange <= distanceToTargetSquared && distanceToTargetSquared <= this.targetMaxRange * this.targetMaxRange) { this.reachedTarget = true; Engine.PostMessage(this.entity, MT_MotionUpdate, { "updateString": "likelySuccess" }); } // 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. let isBehindTarget = ((this.targetX - pos.x) * Math.sin(angle) + (this.targetZ - pos.z) * Math.cos(angle) < 0); // Overshoot the target: carry on straight. if (isBehindTarget && distanceToTargetSquared < this.template.MaxSpeed * this.template.MaxSpeed * this.template.OvershootTime * this.template.OvershootTime) canTurn = false; if (canTurn) { // Turn towards the target. let targetAngle = Math.atan2(this.targetX - pos.x, this.targetZ - pos.z); let delta = targetAngle - angle; // Wrap delta to -pi..pi. delta = (delta + Math.PI) % (2*Math.PI); if (delta < 0) delta += 2 * Math.PI; delta -= Math.PI; // Clamp to max rate. let 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) { let cmpTargetPosition = Engine.QueryInterface(target, IID_Position); if (!cmpTargetPosition || !cmpTargetPosition.IsInWorld()) return false; let 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.SetMemberOfFormation = function() { // Ignored. }; UnitMotionFlying.prototype.GetWalkSpeed = function() { return +this.template.MaxSpeed; }; UnitMotionFlying.prototype.SetSpeedMultiplier = function(multiplier) { // Ignore this, the speed is always the walk speed. }; UnitMotionFlying.prototype.GetRunMultiplier = function() { return 1; }; /** * Estimate the next position of the unit. Just linearly extrapolate. * TODO: Reuse the movement code for a better estimate. */ UnitMotionFlying.prototype.EstimateFuturePosition = function(dt) { let cmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!cmpPosition || !cmpPosition.IsInWorld()) return Vector2D(); let position = cmpPosition.GetPosition2D(); return Vector2D.add(position, Vector2D.sub(position, cmpPosition.GetPreviousPosition2D()).mult(dt/Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer).GetLatestTurnLength())); }; UnitMotionFlying.prototype.IsMoveRequested = function() { return this.hasTarget; }; UnitMotionFlying.prototype.GetCurrentSpeed = function() { return this.speed; }; UnitMotionFlying.prototype.GetSpeedMultiplier = function() { return this.speed / +this.template.MaxSpeed; }; +UnitMotionFlying.prototype.GetAcceleration = function() +{ + return +this.template.AccelRate; +}; + +UnitMotionFlying.prototype.SetAcceleration = function() +{ + // Acceleration is set by the template. Ignore. +}; + 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 25952) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_UnitAI.js (revision 25953) @@ -1,528 +1,533 @@ Engine.LoadHelperScript("Player.js"); Engine.LoadHelperScript("Position.js"); Engine.LoadHelperScript("Sound.js"); Engine.LoadComponentScript("interfaces/Auras.js"); Engine.LoadComponentScript("interfaces/Builder.js"); Engine.LoadComponentScript("interfaces/BuildingAI.js"); Engine.LoadComponentScript("interfaces/Capturable.js"); Engine.LoadComponentScript("interfaces/Garrisonable.js"); Engine.LoadComponentScript("interfaces/Resistance.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/ResourceGatherer.js"); Engine.LoadComponentScript("interfaces/Timer.js"); Engine.LoadComponentScript("interfaces/UnitAI.js"); Engine.LoadComponentScript("Formation.js"); Engine.LoadComponentScript("UnitAI.js"); /** * Fairly straightforward test that entity renaming is handled * by unitAI states. These ought to be augmented with integration tests, ideally. */ function TestTargetEntityRenaming(init_state, post_state, setup) { ResetState(); const player_ent = 5; const target_ent = 6; AddMock(SYSTEM_ENTITY, IID_Timer, { "SetInterval": () => {}, "SetTimeout": () => {} }); AddMock(SYSTEM_ENTITY, IID_ObstructionManager, { "IsInTargetRange": () => false }); let unitAI = ConstructComponent(player_ent, "UnitAI", { "FormationController": "false", "DefaultStance": "aggressive", "FleeDistance": 10 }); unitAI.OnCreate(); setup(unitAI, player_ent, target_ent); TS_ASSERT_EQUALS(unitAI.GetCurrentState(), init_state); unitAI.OnGlobalEntityRenamed({ "entity": target_ent, "newentity": target_ent + 1 }); TS_ASSERT_EQUALS(unitAI.GetCurrentState(), post_state); } TestTargetEntityRenaming( "INDIVIDUAL.GARRISON.APPROACHING", "INDIVIDUAL.IDLE", (unitAI, player_ent, target_ent) => { unitAI.CanGarrison = (target) => target == target_ent; unitAI.MoveToTargetRange = (target) => target == target_ent; unitAI.AbleToMove = () => true; unitAI.Garrison(target_ent, false); } ); TestTargetEntityRenaming( "INDIVIDUAL.REPAIR.REPAIRING", "INDIVIDUAL.REPAIR.REPAIRING", (unitAI, player_ent, target_ent) => { AddMock(player_ent, IID_Builder, { "StartRepairing": () => true, "StopRepairing": () => {} }); QueryBuilderListInterface = () => {}; unitAI.CheckTargetRange = () => true; unitAI.CanRepair = (target) => target == target_ent; unitAI.Repair(target_ent, false, false); } ); TestTargetEntityRenaming( "INDIVIDUAL.FLEEING", "INDIVIDUAL.FLEEING", (unitAI, player_ent, target_ent) => { PositionHelper.DistanceBetweenEntities = () => 10; unitAI.CheckTargetRangeExplicit = () => false; AddMock(player_ent, IID_UnitMotion, { "MoveToTargetRange": () => true, "GetRunMultiplier": () => 1, "SetSpeedMultiplier": () => {}, + "GetAcceleration": () => 1, "StopMoving": () => {} }); unitAI.Flee(target_ent, false); } ); /* 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, accountForSize) { return 1; }, "EnableActiveQuery": function(id) { }, "ResetActiveQuery": function(id) { if (mode == 0) return []; 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 [2]; }, }); 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": () => 1, + "GetAcceleration": () => 1, "MoveToFormationOffset": (target, x, z) => {}, "MoveToTargetRange": (target, min, max) => true, "SetMemberOfFormation": () => {}, "StopMoving": () => {}, "SetFacePointAfterMove": () => {}, "GetFacePointAfterMove": () => true, "GetPassabilityClassName": () => "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; }, "IsTargetInRange": () => true, "StartAttacking": () => true }); unitAI.OnCreate(); unitAI.SetupAttackRangeQuery(1); if (mode == 1) { AddMock(enemy, IID_Health, { "GetHitpoints": function() { return 10; }, }); AddMock(enemy, IID_UnitAI, { "IsAnimal": () => "false", "IsDangerousAnimal": () => "false" }); } else if (mode == 2) AddMock(enemy, IID_Health, { "GetHitpoints": function() { return 0; }, }); let controllerFormation = ConstructComponent(controller, "Formation", { "FormationName": "Line Closed", "FormationShape": "square", "ShiftRows": "false", "SortingClasses": "", "WidthDepthRatio": 1, "UnitSeparationWidthMultiplier": 1, "UnitSeparationDepthMultiplier": 1, "SpeedMultiplier": 1, "Sloppiness": 0 }); let 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; }, "MoveOutOfWorld": () => {} }); AddMock(controller, IID_UnitMotion, { "GetWalkSpeed": () => 1, "StopMoving": () => {}, "SetSpeedMultiplier": () => {}, + "SetAcceleration": (accel) => {}, "MoveToPointRange": () => true, "SetFacePointAfterMove": () => {}, "GetFacePointAfterMove": () => true, "GetPassabilityClassName": () => "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); TS_ASSERT_EQUALS(controllerAI.fsmStateName, "FORMATIONCONTROLLER.WALKING"); TS_ASSERT_EQUALS(unitAI.fsmStateName, "FORMATIONMEMBER.WALKING"); controllerFormation.Disband(); unitAI.UnitFsm.ProcessMessage(unitAI, { "type": "Timer" }); 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, accountForSize) { 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(SYSTEM_ENTITY, IID_ObstructionManager, { "IsInTargetRange": (ent, target, min, max) => true }); AddMock(playerEntity, IID_Player, { "IsAlly": function() { return false; }, "IsEnemy": function() { return true; }, "GetEnemies": function() { return [2]; }, }); // 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": () => 1, + "GetAcceleration": () => 1, "MoveToFormationOffset": (target, x, z) => {}, "MoveToTargetRange": (target, min, max) => true, "SetMemberOfFormation": () => {}, "StopMoving": () => {}, "SetFacePointAfterMove": () => {}, "GetFacePointAfterMove": () => true, "GetPassabilityClassName": () => "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; }, "IsTargetInRange": () => true, "StartAttacking": () => true, "StopAttacking": () => {} }); unitAI.OnCreate(); unitAI.SetupAttackRangeQuery(1); unitAIs.push(unitAI); } // create enemy AddMock(enemy, IID_Health, { "GetHitpoints": function() { return 40; }, }); let controllerFormation = ConstructComponent(controller, "Formation", { "FormationName": "Line Closed", "FormationShape": "square", "ShiftRows": "false", "SortingClasses": "", "WidthDepthRatio": 1, "UnitSeparationWidthMultiplier": 1, "UnitSeparationDepthMultiplier": 1, "SpeedMultiplier": 1, "Sloppiness": 0 }); let controllerAI = ConstructComponent(controller, "UnitAI", { "FormationController": "true", "DefaultStance": "aggressive" }); AddMock(controller, IID_Position, { "GetTurretParent": () => 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": () => ({ "y": 0 }), "IsInWorld": () => true, "MoveOutOfWorld": () => {}, }); AddMock(controller, IID_UnitMotion, { "GetWalkSpeed": () => 1, "SetSpeedMultiplier": (speed) => {}, + "SetAcceleration": (accel) => {}, "MoveToPointRange": (x, z, minRange, maxRange) => {}, "StopMoving": () => {}, "SetFacePointAfterMove": () => {}, "GetFacePointAfterMove": () => true, "GetPassabilityClassName": () => "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 (let ent of unitAIs) TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.COMBAT.ATTACKING"); controllerAI.MoveIntoFormation({ "name": "Circle" }); // let all units be in position for (let ent of unitAIs) controllerFormation.SetFinishedEntity(ent); for (let ent of unitAIs) TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.COMBAT.ATTACKING"); controllerFormation.Disband(); } TestFormationExiting(0); TestFormationExiting(1); TestFormationExiting(2); TestMoveIntoFormationWhileAttacking(); function TestWalkAndFightTargets() { const ent = 10; let unitAI = ConstructComponent(ent, "UnitAI", { "FormationController": "false", "DefaultStance": "aggressive", "FleeDistance": 10 }); unitAI.OnCreate(); unitAI.losAttackRangeQuery = true; // The result is stored here let result; unitAI.PushOrderFront = function(type, order) { if (type === "Attack" && order?.target) result = order.target; }; // Create some targets. AddMock(ent+1, IID_UnitAI, { "IsAnimal": () => true, "IsDangerousAnimal": () => false }); AddMock(ent+2, IID_Ownership, { "GetOwner": () => 2 }); AddMock(ent+3, IID_Ownership, { "GetOwner": () => 2 }); AddMock(ent+4, IID_Ownership, { "GetOwner": () => 2 }); AddMock(ent+5, IID_Ownership, { "GetOwner": () => 2 }); AddMock(ent+6, IID_Ownership, { "GetOwner": () => 2 }); AddMock(ent+7, IID_Ownership, { "GetOwner": () => 2 }); unitAI.CanAttack = function(target) { return target !== ent+2 && target !== ent+7; }; AddMock(ent, IID_Attack, { "GetPreference": (target) => ({ [ent+4]: 0, [ent+5]: 1, [ent+6]: 2, [ent+7]: 0 }?.[target]) }); let runTest = function(ents, res) { result = undefined; AddMock(SYSTEM_ENTITY, IID_RangeManager, { "ResetActiveQuery": () => ents }); TS_ASSERT_EQUALS(unitAI.FindWalkAndFightTargets(), !!res); TS_ASSERT_EQUALS(result, res); }; // No entities. runTest([]); // Entities that cannot be attacked. runTest([ent+1, ent+2, ent+7]); // No preference, one attackable entity. runTest([ent+1, ent+2, ent+3], ent+3); // Check preferences. runTest([ent+1, ent+2, ent+3, ent+4], ent+4); runTest([ent+1, ent+2, ent+3, ent+4, ent+5], ent+4); runTest([ent+1, ent+2, ent+6, ent+3, ent+4, ent+5], ent+4); runTest([ent+1, ent+2, ent+7, ent+6, ent+3, ent+4, ent+5], ent+4); runTest([ent+1, ent+2, ent+7, ent+6, ent+3, ent+5], ent+5); runTest([ent+1, ent+2, ent+7, ent+6, ent+3], ent+6); runTest([ent+1, ent+2, ent+7, ent+3], ent+3); } TestWalkAndFightTargets(); Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_camel.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_camel.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_camel.xml (revision 25953) @@ -1,34 +1,35 @@ 5.5 50 Dromedary Camelus dromedarius gaia/fauna_camel.png 200 actor/fauna/movement/camel_order.xml actor/fauna/death/death_camel.xml 6.5 0.45 + 0.45 fauna/camel.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_chicken.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_chicken.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_chicken.xml (revision 25953) @@ -1,49 +1,50 @@ 1.5 5 Chicken Gallus gallus domesticus gaia/fauna_chicken.png false upright 40 5 actor/fauna/animal/chickens.xml actor/fauna/animal/chickens.xml 2.5 4.0 12.0 2000 8000 10000 40000 0.15 + 0.15 fauna/chicken.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_crocodile_nile.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_crocodile_nile.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_crocodile_nile.xml (revision 25953) @@ -1,54 +1,55 @@ Teeth 30 7 1000 2000 Structure Ship Siege 2.0 100 Nile Crocodile Crocodylus niloticus gaia/fauna_crocodile.png pitch-roll 128x512/ellipse.png 128x512/ellipse_mask.png actor/fauna/animal/lion_attack.xml actor/fauna/animal/lion_death.xml 3.0 0.3 + 0.3 fauna/crocodile.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_donkey.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_donkey.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_donkey.xml (revision 25953) @@ -1,35 +1,36 @@ 2.5 50 Donkey Equus africanus asinus gaia/fauna_donkey.png 200 actor/fauna/animal/horse_order.xml actor/fauna/animal/horse_death.xml actor/fauna/animal/horse_trained.xml 3.5 0.8 + 0.8 fauna/donkey.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_giraffe.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_giraffe.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_giraffe.xml (revision 25953) @@ -1,27 +1,28 @@ 13.0 80 Giraffe Giraffa camelopardalis gaia/fauna_giraffe.png 350 14.0 0.6 + 0.6 fauna/giraffe_adult.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_giraffe_infant.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_giraffe_infant.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_giraffe_infant.xml (revision 25953) @@ -1,27 +1,28 @@ 7.5 40 Juvenile Giraffe Giraffa camelopardalis gaia/fauna_giraffe.png 150 8.5 0.6 + 0.6 fauna/giraffe_baby.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_goat.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_goat.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_goat.xml (revision 25953) @@ -1,42 +1,43 @@ 30 35 2.0 35 Goat Capra aegagrus hircus gaia/fauna_goat.png 70 2 actor/fauna/animal/goat_order.xml actor/fauna/death/goat.xml actor/fauna/animal/goat_trained.xml 3.0 0.45 + 0.45 fauna/goat.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_horse.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_horse.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_horse.xml (revision 25953) @@ -1,35 +1,36 @@ 4.0 50 Horse Equus ferus caballus gaia/fauna_horse.png 200 actor/fauna/animal/horse_order.xml actor/fauna/animal/horse_death.xml actor/fauna/animal/horse_trained.xml 5.0 0.8 + 0.8 fauna/horse.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_lion.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_lion.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_lion.xml (revision 25953) @@ -1,43 +1,44 @@ Paws 20 3 1000 2000 Structure Ship Siege 3.0 50 Lion Panthera leo gaia/fauna_lion.png actor/fauna/animal/lion_attack.xml actor/fauna/animal/lion_death.xml 4.0 0.45 + 0.45 fauna/lion.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_peacock.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_peacock.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_peacock.xml (revision 25953) @@ -1,47 +1,48 @@ 1.5 10 Peacock Pavo cristatus gaia/fauna_peacock.png upright 50 5 actor/fauna/animal/peacock_order.xml actor/fauna/animal/peacock_call.xml actor/fauna/animal/peacock_trained.xml 2.5 4.0 12.0 2000 8000 10000 40000 0.3 + 0.3 fauna/peacock.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_pig.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_pig.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_pig.xml (revision 25953) @@ -1,42 +1,43 @@ 50 75 1.5 75 Pig Sus scrofa domesticus gaia/fauna_pig.png 150 4 actor/fauna/animal/pig_order.xml actor/fauna/animal/pig.xml actor/fauna/animal/pig_trained.xml 2.5 0.45 + 0.45 fauna/pig1.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_pig_flaming.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_pig_flaming.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_pig_flaming.xml (revision 25953) @@ -1,37 +1,38 @@ 10.0 0.05 0.1 corpse -2 0.80 0.50 0.20 Flaming Pig units/flaming_pig.png actor/fauna/animal/pig_flaming_order.xml actor/fauna/animal/pig_death.xml actor/fauna/animal/pig_flaming_trained.xml 3.5 + 3.5 fauna/pig_flaming.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_piglet.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_piglet.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_piglet.xml (revision 25953) @@ -1,26 +1,27 @@ 0.5 15 Piglet 10 1 1.5 0.25 + 0.25 fauna/piglet.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_shark.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_shark.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_shark.xml (revision 25953) @@ -1,55 +1,56 @@ 2.5 150 true Great White Shark Carcharodon carcharias SeaCreature gaia/fauna_shark.png false false -1 upright true 0 5 128x512/ellipse.png 128x512/ellipse_mask.png 100.0 60.0 100000 300000 1 2 ship-small 0.6 + 0.6 false fauna/shark.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_sheep.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_sheep.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_sheep.xml (revision 25953) @@ -1,42 +1,43 @@ 40 50 2.0 50 Sheep Ovis aries gaia/fauna_sheep.png 100 3 actor/fauna/animal/sheep_order.xml actor/fauna/animal/sheep.xml actor/fauna/animal/sheep_trained.xml 3.0 0.45 + 0.45 fauna/sheep3.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_wildebeest.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_wildebeest.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_wildebeest.xml (revision 25953) @@ -1,27 +1,28 @@ 3.0 50 Blue Wildebeest Connochaetes taurinus gaia/fauna_wildebeest.png 150 4.0 0.9 + 0.9 fauna/wildebeest.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_zebra.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_zebra.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/gaia/fauna_zebra.xml (revision 25953) @@ -1,27 +1,28 @@ 2.5 50 Common Zebra Equus quagga gaia/fauna_zebra.png 150 3.5 0.9 + 0.9 fauna/zebra.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_formation.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_formation.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_formation.xml (revision 25953) @@ -1,76 +1,78 @@ 2 Requires at least 2 Soldiers or Siege Engines. 1 square Hero Champion Cavalry Melee Ranged false 1 1 1 0 false true false false false false false false false 0 upright false 0 10 0.75 aggressive true 12.0 true true 2 true - 1.0 - 100.0 + 1 + 100 + 0.1 + 100 large Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit.xml (revision 25953) @@ -1,146 +1,148 @@ 1 1 0 0 0 0 false false 30.0 0.01 0.0 4.0 1 corpse 100 0 0 false gaia Unit Organic ConquestCritical Unit special/formations/null special/formations/box special/formations/column_closed special/formations/line_closed special/formations/column_open special/formations/line_open special/formations/flank special/formations/battle_line male false unit true true false false true false false false 0 pitch false 0.0 14 1 1 1 128x128/ellipse.png 128x128/ellipse_mask.png interface/alarm/alarm_attackplayer.xml interface/alarm/alarm_attacked_gaia.xml interface/alarm/alarm_attackplayer.xml interface/alarm/alarm_attacked_gaia.xml voice/{lang}/civ/civ_{phenotype}_collect_treasure.xml 2.0 0.333 5.0 2 aggressive 12.0 false true true 1 2800 false default - 9.0 + 9 1.67 + 1.5 + 18 false false false false 12 false true false false Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_cavalry_ranged_archer.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_cavalry_ranged_archer.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_cavalry_ranged_archer.xml (revision 25953) @@ -1,40 +1,41 @@ Bow 7 60 800 1000 100 2.5 50 false Human 50 Archer Cavalry Archer attack/impact/arrow_impact.xml attack/weapon/bow_attack.xml 0.85 + 0.85 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_cavalry_ranged_javelineer.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_cavalry_ranged_javelineer.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_cavalry_ranged_javelineer.xml (revision 25953) @@ -1,40 +1,41 @@ Javelin 18 30 400 1250 70 4 35 false Human 50 Cavalry Javelineer Javelineer attack/weapon/javelin_attack.xml attack/impact/javelin_impact.xml 0.9 + 0.9 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_cavalry_crossbowman.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_cavalry_crossbowman.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_cavalry_crossbowman.xml (revision 25953) @@ -1,46 +1,47 @@ Crossbow 40 60 200 3000 120 0.8 30 false Human Champion Cavalry Crossbowman Ranged Crossbowman special/formations/skirmish -2 -2 attack/impact/arrow_impact.xml attack/weapon/bow_attack.xml 0.8 + 0.8 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_elephant.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_elephant.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_elephant.xml (revision 25953) @@ -1,62 +1,63 @@ 3 36 300 200 9.0 1000 War Elephant Elephant 300 30 20 4 7 5 20 actor/fauna/animal/elephant_order.xml actor/fauna/animal/elephant_attack_order.xml actor/fauna/animal/elephant_order.xml actor/fauna/animal/elephant_order.xml actor/fauna/animal/elephant_attack.xml actor/mounted/movement/walk.xml actor/mounted/movement/walk.xml actor/fauna/animal/elephant_death.xml actor/fauna/animal/elephant_trained.xml 10.0 large + 0.5 100 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_infantry_javelineer.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_infantry_javelineer.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_infantry_javelineer.xml (revision 25953) @@ -1,50 +1,51 @@ Javelin 32 30 400 1250 70 1.6 35 false Human 120 Champion Infantry Javelineer Ranged Javelineer special/formations/skirmish -2 -2 attack/weapon/javelin_attack.xml attack/impact/javelin_impact.xml 1.2 + 1.2 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_elephant.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_elephant.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_elephant.xml (revision 25953) @@ -1,61 +1,62 @@ 9.0 War Elephant Basic Human CitizenSoldier Citizen Soldier Elephant 260 4 150 4 3 15 128x256/ellipse.png 128x256/ellipse_mask.png actor/fauna/animal/elephant_order.xml actor/fauna/animal/elephant_attack_order.xml actor/fauna/animal/elephant_order.xml actor/fauna/animal/elephant_order.xml actor/mounted/movement/walk.xml actor/mounted/movement/walk.xml actor/fauna/animal/elephant_death.xml actor/fauna/animal/elephant_trained.xml 10.0 large + 0.5 100 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna_hunt_defensive_bull.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna_hunt_defensive_bull.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna_hunt_defensive_bull.xml (revision 25953) @@ -1,20 +1,21 @@ 50 actor/fauna/animal/cattle_attack.xml actor/fauna/animal/cattle_death.xml actor/fauna/animal/cattle_order.xml actor/fauna/animal/cattle_trained.xml large 0.4 2.0 + 0.4 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna_wild_aggressive_dog.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna_wild_aggressive_dog.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna_wild_aggressive_dog.xml (revision 25953) @@ -1,18 +1,19 @@ voice/global/civ_dog_move.xml voice/global/civ_dog_move.xml actor/mounted/movement/walk.xml actor/mounted/movement/walk.xml attack/weapon/sword.xml actor/fauna/death/death_animal_gen.xml interface/complete/building/complete_kennel.xml 1.6 + 1.6 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_hero_cavalry_javelineer.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_hero_cavalry_javelineer.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_hero_cavalry_javelineer.xml (revision 25953) @@ -1,46 +1,47 @@ Javelin 60 30 400 1250 70 0.8 35 false Human Hero Cavalry Javelineer Ranged Javelineer special/formations/skirmish -2 -2 attack/weapon/javelin_attack.xml attack/impact/javelin_impact.xml 0.9 + 0.9 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_infantry_crossbowman.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_infantry_crossbowman.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_infantry_crossbowman.xml (revision 25953) @@ -1,50 +1,51 @@ Crossbow 40 60 200 3000 120 0.8 30 false Human 120 Champion Infantry Crossbowman Ranged Crossbowman special/formations/skirmish -2 -2 attack/impact/arrow_impact.xml attack/weapon/bow_attack.xml 0.6 + 1.2 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_dog.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_dog.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_dog.xml (revision 25953) @@ -1,74 +1,75 @@ Fangs 7 2 3 500 1000 Structure Ship Siege 15 0 100 1.5 110 War Dog Cannot attack Structures, Ships, or Siege Engines. Human FastMoving Dog Melee 100 10 128x256/ellipse.png 128x256/ellipse_mask.png voice/global/civ_dog_move.xml voice/global/civ_dog_move.xml actor/mounted/movement/walk.xml actor/mounted/movement/walk.xml attack/weapon/sword.xml actor/fauna/death/death_animal_gen.xml interface/complete/building/complete_kennel.xml 2.5 WarDog 1.5 2 + 1.5 30 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna_herd_domestic_cattle.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna_herd_domestic_cattle.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna_herd_domestic_cattle.xml (revision 25953) @@ -1,15 +1,16 @@ actor/fauna/animal/cattle_order.xml actor/fauna/animal/cattle_death.xml actor/fauna/animal/cattle_trained.xml 0.4 1.4 + 0.4 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna_hunt_whale.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna_hunt_whale.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna_hunt_whale.xml (revision 25953) @@ -1,59 +1,60 @@ true SeaCreature Kill to butcher for food. gaia/fauna_whale.png 10 false false upright true 0.0 true 2000 food.fish 5 128x512/ellipse.png 128x512/ellipse_mask.png 4.0 0.666 5.0 skittish 60.0 60.0 100000 300000 1 2 0 ship-small 1.8 1 + 1.8 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_hero_cavalry_crossbowman.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_hero_cavalry_crossbowman.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_hero_cavalry_crossbowman.xml (revision 25953) @@ -1,46 +1,47 @@ Crossbow 80 60 200 3000 120 0.4 30 false Human Hero Cavalry Crossbowman Ranged Crossbowman special/formations/skirmish -2 -2 attack/impact/arrow_impact.xml attack/weapon/bow_attack.xml 0.8 + 0.8 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry_melee_pikeman.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry_melee_pikeman.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry_melee_pikeman.xml (revision 25953) @@ -1,54 +1,55 @@ Pike 2 3 8 1000 2000 Cavalry 3.0 Human 50 Pikeman Counters: 3× vs Cavalry. Pikeman special/formations/syntagma 5 10 10 attack/weapon/pike_attack.xml 0.9 + 0.9 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_catafalque.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_catafalque.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_catafalque.xml (revision 25953) @@ -1,59 +1,60 @@ 250 0 10 2.0 -Organic -ConquestCritical Relic Catafalque units/catafalque.png template_unit_catafalque A catafalque that holds the remains of a great leader. true pitch-roll 128x256/cartouche.png 128x256/cartouche_mask.png actor/singlesteps/steps_grass_order.xml actor/singlesteps/steps_grass.xml actor/singlesteps/steps_grass.xml standground false large 0.55 + 0.275 units/global/catafalque.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_cavalry_ranged_crossbowman.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_cavalry_ranged_crossbowman.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_cavalry_ranged_crossbowman.xml (revision 25953) @@ -1,45 +1,46 @@ Crossbow 20 60 200 3000 120 2 30 false Human 30 20 Cavalry Crossbowman Crossbowman 3 2 attack/impact/arrow_impact.xml attack/weapon/bow_attack.xml 0.8 + 0.8 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_cavalry_archer.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_cavalry_archer.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_cavalry_archer.xml (revision 25953) @@ -1,46 +1,47 @@ Bow 14 60 800 1000 100 1 50 false Human Ranged Archer Champion Cavalry Archer special/formations/skirmish -2 -2 attack/impact/arrow_impact.xml attack/weapon/bow_attack.xml 0.85 + 0.85 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_cavalry_javelineer.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_cavalry_javelineer.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_cavalry_javelineer.xml (revision 25953) @@ -1,46 +1,47 @@ Javelin 36 30 400 1250 70 1.6 35 false Human Champion Cavalry Javelineer Ranged Javelineer special/formations/skirmish -2 -2 attack/weapon/javelin_attack.xml attack/impact/javelin_impact.xml 0.9 + 0.9 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_infantry_archer.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_infantry_archer.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_infantry_archer.xml (revision 25953) @@ -1,50 +1,51 @@ Bow 13.5 60 800 1000 100 1 50 false Human 120 Ranged Archer Champion Archer special/formations/skirmish -2 -2 attack/impact/arrow_impact.xml attack/weapon/bow_attack.xml 1.2 + 2.4 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_infantry_pikeman.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_infantry_pikeman.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_champion_infantry_pikeman.xml (revision 25953) @@ -1,50 +1,51 @@ Pike 4 6 8 1000 2000 Cavalry 3.0 Human 200 Champion Pikeman Counters: 3× vs Cavalry. Melee Pikeman special/formations/syntagma 8 8 20 attack/weapon/pike_attack.xml 0.9 + 0.9 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna.xml (revision 25953) @@ -1,56 +1,57 @@ 0 4.0 -ConquestCritical Animal gaia/fauna_generic.png food 4 128x256/ellipse.png 128x256/ellipse_mask.png Animal passive false false 8.0 24.0 2000 8000 15000 60000 0.7 + 0.7 true 60 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna_hunt_defensive_elephant.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna_hunt_defensive_elephant.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_fauna_hunt_defensive_elephant.xml (revision 25953) @@ -1,25 +1,26 @@ Elephant 50 4 actor/fauna/animal/elephant_order.xml actor/fauna/animal/elephant_attack.xml actor/fauna/animal/elephant_death.xml actor/fauna/animal/elephant_trained.xml large 0.5 + 0.25 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_hero_cavalry_archer.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_hero_cavalry_archer.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_hero_cavalry_archer.xml (revision 25953) @@ -1,46 +1,47 @@ Bow 28 60 800 1000 100 0.5 50 false Human Ranged Archer Hero Cavalry Archer special/formations/skirmish -2 -2 attack/impact/arrow_impact.xml attack/weapon/bow_attack.xml 0.85 + 0.85 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_hero_elephant_melee.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_hero_elephant_melee.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_hero_elephant_melee.xml (revision 25953) @@ -1,77 +1,78 @@ Trunk 60 240 5 750 1500 !Ship 600 400 9.0 1500 Hero Elephant Elephant Melee 60 40 4 10 10 25 actor/fauna/animal/elephant_order.xml actor/fauna/animal/elephant_attack_order.xml actor/fauna/animal/elephant_order.xml actor/fauna/animal/elephant_order.xml actor/fauna/animal/elephant_attack.xml actor/mounted/movement/walk.xml actor/mounted/movement/walk.xml actor/fauna/animal/elephant_death.xml actor/fauna/animal/elephant_trained.xml 10.0 large + 0.5 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged_javelineer.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged_javelineer.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged_javelineer.xml (revision 25953) @@ -1,51 +1,52 @@ Javelin 16 30 400 1250 70 4 35 false Human 50 Infantry Javelineer Javelineer 5 1 1 attack/weapon/javelin_attack.xml attack/impact/javelin_impact.xml 2.4 + 4.8 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_fire.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_fire.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_fire.xml (revision 25953) @@ -1,54 +1,55 @@ Fire 20 12 50 100 !Ship 30 300 Circular 30 true 600 500 -6 0.85 0.65 0.35 Fire Ship Unrepairable. Gradually loses health. Can only attack Ships. Melee Warship Fireship phase_town ship-small 1.6 + 1.6 60 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_trireme.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_trireme.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_trireme.xml (revision 25953) @@ -1,69 +1,70 @@ Bow 35 55 1000 2000 100 2 50 false Ship Human 3 13 1 Infantry Cavalry 3 25 200 100 30 Support Soldier Siege 1400 Medium Warship Garrison units for transport and to increase firepower. Ranged Warship Trireme phase_town 140 40 20 4 attack/impact/arrow_impact.xml attack/weapon/bow_attack.xml 1.8 + 1.8 90 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_siege_stonethrower.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_siege_stonethrower.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_siege_stonethrower.xml (revision 25953) @@ -1,71 +1,72 @@ Stone 210 100 40 6000 7000 40 6 20 false props/units/weapons/rock_explosion.xml 0.1 Structure outline_border.png outline_border_mask.png 0.175 25 400 250 4.5 375 Ranged StoneThrower Siege Catapult 250 80 50 attack/impact/siegeprojectilehit.xml attack/siege/ballist_attack.xml standground 0.8 + 0.8 100 Index: ps/trunk/binaries/data/mods/public/simulation/templates/units/gaul/champion_fanatic.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/units/gaul/champion_fanatic.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/units/gaul/champion_fanatic.xml (revision 25953) @@ -1,24 +1,25 @@ gaul Naked Fanatic Bariogaisatos units/gaul_champion_fanatic.png phase_town -4 -4 1.4 + 1.4 units/gauls/infantry_spearman_c.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/units/rome/champion_infantry_spear_gladiator.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/units/rome/champion_infantry_spear_gladiator.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/units/rome/champion_infantry_spear_gladiator.xml (revision 25953) @@ -1,46 +1,47 @@ structures/rome/army_camp structures/rome/temple_vesta -10 20 5 Gladiator rome latin Gladiator Spearman Hoplomachus Elite units/rome_champion_infantry_gladiator_spear.png phase_town -2 Gladiator 1.5 + 1.5 0.5 units/romans/infantry_gladiator_spearman.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged_crossbowman.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged_crossbowman.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged_crossbowman.xml (revision 25953) @@ -1,53 +1,54 @@ Crossbow 20 60 200 3000 120 2 30 false Human 30 20 Infantry Crossbowman Crossbowman 3 2 1 1 attack/impact/arrow_impact.xml attack/weapon/bow_attack.xml 0.6 + 1.2 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_bireme.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_bireme.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_bireme.xml (revision 25953) @@ -1,66 +1,67 @@ Bow 35 45 1000 2000 100 2 50 false Ship Human 2 10 1 Infantry Cavalry 2 20 120 60 20 Support Cavalry 800 Light Warship Garrison units for transport and to increase firepower. Ranged Warship Bireme phase_town 80 24 12 attack/impact/arrow_impact.xml attack/weapon/bow_attack.xml 1.55 + 1.55 90 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_quinquereme.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_quinquereme.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_quinquereme.xml (revision 25953) @@ -1,74 +1,75 @@ Stone 150 100 40 2000 5000 40 6 20 false Ship Structure outline_border.png outline_border_mask.png 0.175 1 10 1 StoneThrower 5 30 600 300 50 Support Soldier Siege 2000 Heavy Warship Garrison units for transport and to increase firepower. Ranged Warship Quinquereme phase_city 200 120 60 4 attack/siege/ballist_attack.xml 1.8 + 1.8 110 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_siege_ram.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_siege_ram.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_siege_ram.xml (revision 25953) @@ -1,66 +1,67 @@ Ram 150 6.5 750 1500 Structure Field Organic 30 300 150 10 0.1 Unit Support Infantry 0 2 400 Battering Ram Cannot attack Fields or Organic Units. Melee Ram 200 60 30 50 attack/siege/ram_move.xml attack/siege/ram_attack_order.xml attack/siege/ram_trained.xml attack/siege/ram_attack.xml 0.8 + 0.8 80 Index: ps/trunk/binaries/data/mods/public/simulation/templates/units/brit/ship_trireme.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/units/brit/ship_trireme.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/units/brit/ship_trireme.xml (revision 25953) @@ -1,30 +1,31 @@ 40 8.0 40 200 brit Pontos units/celt_ship_trireme.png 20 0.9 + 0.9 structures/celts/warship.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/units/maur/ship_trireme.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/units/maur/ship_trireme.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/units/maur/ship_trireme.xml (revision 25953) @@ -1,30 +1,31 @@ 40 8.0 40 200 maur Yudhpot units/maur_ship_trireme.png 20 0.9 + 0.9 structures/mauryas/trireme.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged_archer.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged_archer.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged_archer.xml (revision 25953) @@ -1,52 +1,53 @@ Bow 6.7 60 800 1000 100 2.5 50 false Human 50 50 Archer Archer 5 1 1 attack/impact/arrow_impact.xml attack/weapon/bow_attack.xml 1.2 + 2.4 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship.xml (revision 25953) @@ -1,77 +1,79 @@ true 0.0 0.5 7.0 10.0 10 10 FemaleCitizen Infantry Healer Dog 0 FemaleCitizen Infantry Healer Dog 0 10 true true Ship -Organic Ship upright true 0 4 4.0 5 10 5 128x512/ellipse.png 128x512/ellipse_mask.png interface/alarm/alarm_create_warship.xml actor/ship/warship_move_01.xml actor/ship/warship_move_01.xml actor/ship/warship_move_01.xml actor/ship/warship_death.xml 6.0 0.5 6.0 ship + 0.5 + 0.25 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_merchant.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_merchant.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_merchant.xml (revision 25953) @@ -1,49 +1,50 @@ 20 100 15 Support Cavalry 240 Merchantman template_unit_ship_merchant Trade between docks. Garrison a Trader aboard for additional profit (+20% for each garrisoned). Gather profitable aquatic treasures. -ConquestCritical Trader Bribable phase_town 20 0.75 0.2 12 passive false false ship-small 1.35 + 1.6 50 true Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_siege_boltshooter.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_siege_boltshooter.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_siege_boltshooter.xml (revision 25953) @@ -1,78 +1,79 @@ Bolt 240 90 15 5000 6000 150 1 9.81 false Human outline_border.png outline_border_mask.png 0.175 Linear 9 false 80 20 250 250 2 2.0 200 Ranged BoltShooter Bolt Shooter 200 50 50 attack/impact/arrow_metal.xml attack/weapon/arrowfly.xml standground 0.9 + 0.9 100 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_support_elephant.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_support_elephant.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_support_elephant.xml (revision 25953) @@ -1,77 +1,78 @@ units/elephant_worker 15 100 8.0 300 Worker Elephant Elephant 10 pitch 4 2 2 10 food wood stone metal false 128x256/ellipse.png 128x256/ellipse_mask.png actor/fauna/animal/elephant_order.xml actor/fauna/animal/elephant_order.xml actor/fauna/animal/elephant_order.xml actor/mounted/movement/walk.xml actor/mounted/movement/walk.xml actor/fauna/animal/elephant_death.xml actor/fauna/animal/elephant_trained.xml 9.0 false large 0.6 + 0.3 50 Index: ps/trunk/binaries/data/mods/public/simulation/templates/units/iber/ship_trireme.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/units/iber/ship_trireme.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/units/iber/ship_trireme.xml (revision 25953) @@ -1,31 +1,32 @@ 40 8.0 40 200 iber Ponti Transport many soldiers across the sea. units/celt_ship_trireme.png 20 0.9 + 0.9 structures/iberians/warship.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged_slinger.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged_slinger.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_infantry_ranged_slinger.xml (revision 25953) @@ -1,53 +1,54 @@ Sling 11.5 1.1 45 400 1250 90 3 45 false Human 30 20 Slinger Slinger 3 2 1 1 attack/weapon/sling_attack.xml 1.2 + 1.2 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_fishing.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_fishing.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_ship_fishing.xml (revision 25953) @@ -1,78 +1,79 @@ Harpoon 10 5 500 1000 !SeaCreature 15 50 1 120 Fishing Boat template_unit_ship_fishing Fish the waters for food. -ConquestCritical FishingBoat 10 6.0 1.0 1.8 40 128x256/ellipse.png 128x256/ellipse_mask.png actor/ship/boat_move.xml actor/ship/boat_move.xml 2.0 0.333 5.0 passive false false ship-small 1.1 + 1.1 30 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_siege.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_siege.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_siege.xml (revision 25953) @@ -1,64 +1,66 @@ 3 0.0 0.0 2.0 true -Organic Siege Siege phase_city pitch-roll 4 4.0 1 25 5 128x256/rounded_rectangle.png 128x256/rounded_rectangle_mask.png attack/siege/ram_move.xml attack/siege/ram_move.xml attack/siege/ram_trained.xml 4.0 0.5 false large 1 + 0.75 + 0.25 Index: ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_siege_tower.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_siege_tower.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/template_unit_siege_tower.xml (revision 25953) @@ -1,102 +1,103 @@ Bow 12 2.5 55 10 10 1200 2000 100 2 50 false Human outline_border.png outline_border_mask.png 0.175 0 1 10 Infantry 40 500 300 30.0 20 0.1 Unit Support Infantry 0 2 500 Ranged SiegeTower Siege Tower Garrison units for transport and to increase firepower. 250 100 60 256x256/rounded_rectangle.png 256x256/rounded_rectangle_mask.png attack/siege/ram_move.xml attack/siege/ram_move.xml attack/impact/arrow_metal.xml attack/weapon/arrowfly.xml attack/siege/ram_trained.xml 12.0 50 0.7 + 0.7 80 Index: ps/trunk/binaries/data/mods/public/simulation/templates/units/gaul/ship_trireme.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/units/gaul/ship_trireme.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/units/gaul/ship_trireme.xml (revision 25953) @@ -1,30 +1,31 @@ 40 8.0 40 200 gaul Pontos units/celt_ship_trireme.png 20 0.9 + 0.9 structures/celts/warship.xml Index: ps/trunk/binaries/data/mods/public/simulation/templates/units/rome/champion_infantry_sword_gladiator.xml =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/templates/units/rome/champion_infantry_sword_gladiator.xml (revision 25952) +++ ps/trunk/binaries/data/mods/public/simulation/templates/units/rome/champion_infantry_sword_gladiator.xml (revision 25953) @@ -1,47 +1,48 @@ structures/rome/army_camp structures/rome/temple_vesta -10 20 5 Gladiator rome latin Gladiator Swordsman Murmillo Elite units/rome_champion_infantry_gladiator_sword.png phase_town -1 -1 Gladiator 1.4 + 1.4 0.5 units/romans/infantry_gladiator_swordsman.xml Index: ps/trunk/source/simulation2/components/CCmpUnitMotion_System.cpp =================================================================== --- ps/trunk/source/simulation2/components/CCmpUnitMotion_System.cpp (revision 25952) +++ ps/trunk/source/simulation2/components/CCmpUnitMotion_System.cpp (revision 25953) @@ -1,343 +1,344 @@ /* Copyright (C) 2021 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 "CCmpUnitMotion.h" #include "CCmpUnitMotionManager.h" #include "maths/MathUtil.h" #include "ps/CLogger.h" #include "ps/Profile.h" #include // NB: this TU contains the CCmpUnitMotion/CCmpUnitMotionManager couple. // In practice, UnitMotionManager functions need access to the full implementation of UnitMotion, // but UnitMotion needs access to MotionState (defined in UnitMotionManager). // To avoid inclusion issues, implementation of UnitMotionManager that uses UnitMotion is here. namespace { /** * Units push only within their own grid square. This is the size of each square (in arbitrary units). * TODO: check other values. */ static const int PUSHING_GRID_SIZE = 20; /** * For pushing, treat the clearances as a circle - they're defined as squares, * so we'll take the circumscribing square (approximately). * Clerances are also full-width instead of half, so we want to divide by two. sqrt(2)/2 is about 0.71 < 5/7. */ static const entity_pos_t PUSHING_CORRECTION = entity_pos_t::FromInt(5) / 7; /** * Arbitrary constant used to reduce pushing to levels that won't break physics for our turn length. */ static const int PUSHING_REDUCTION_FACTOR = 2; /** * Maximum distance multiplier. * NB: this value interacts with the "minimal pushing" force, * as two perfectly overlapping units exert MAX_DISTANCE_FACTOR * Turn length in ms / REDUCTION_FACTOR * of force on each other each turn. If this is below the minimal pushing force, any 2 units can entirely overlap. */ static const entity_pos_t MAX_DISTANCE_FACTOR = entity_pos_t::FromInt(5) / 2; } CCmpUnitMotionManager::MotionState::MotionState(CmpPtr cmpPos, CCmpUnitMotion* cmpMotion) : cmpPosition(cmpPos), cmpUnitMotion(cmpMotion) { } void CCmpUnitMotionManager::Init(const CParamNode&) { // Load some data - see CCmpPathfinder.xml. // This assumes the pathfinder component is initialised first and registers the validator. // TODO: there seems to be no real reason why we could not register a 'system' entity somewhere instead. CParamNode externalParamNode; CParamNode::LoadXML(externalParamNode, L"simulation/data/pathfinder.xml", "pathfinder"); CParamNode pushingNode = externalParamNode.GetChild("Pathfinder").GetChild("Pushing"); // NB: all values are given sane default, but they are not treated as optional in the schema, // so the XML file is the reference. const CParamNode radius = pushingNode.GetChild("Radius"); if (radius.IsOk()) { m_PushingRadius = radius.ToFixed(); if (m_PushingRadius < entity_pos_t::Zero()) { LOGWARNING("Pushing radius cannot be below 0. De-activating pushing but 'pathfinder.xml' should be updated."); m_PushingRadius = entity_pos_t::Zero(); } // No upper value, but things won't behave sanely if values are too high. } else m_PushingRadius = entity_pos_t::FromInt(8) / 5; const CParamNode minForce = pushingNode.GetChild("MinimalForce"); if (minForce.IsOk()) m_MinimalPushing = minForce.ToFixed(); else m_MinimalPushing = entity_pos_t::FromInt(2) / 10; const CParamNode movingExt = pushingNode.GetChild("MovingExtension"); const CParamNode staticExt = pushingNode.GetChild("StaticExtension"); if (movingExt.IsOk() && staticExt.IsOk()) { m_MovingPushExtension = movingExt.ToFixed(); m_StaticPushExtension = staticExt.ToFixed(); } else { m_MovingPushExtension = entity_pos_t::FromInt(5) / 2; m_StaticPushExtension = entity_pos_t::FromInt(2); } } void CCmpUnitMotionManager::Register(CCmpUnitMotion* component, entity_id_t ent, bool formationController) { MotionState state(CmpPtr(GetSimContext(), ent), component); if (!formationController) m_Units.insert(ent, state); else m_FormationControllers.insert(ent, state); } void CCmpUnitMotionManager::Unregister(entity_id_t ent) { EntityMap::iterator it = m_Units.find(ent); if (it != m_Units.end()) { m_Units.erase(it); return; } it = m_FormationControllers.find(ent); if (it != m_FormationControllers.end()) m_FormationControllers.erase(it); } void CCmpUnitMotionManager::OnTurnStart() { for (EntityMap::value_type& data : m_FormationControllers) data.second.cmpUnitMotion->OnTurnStart(); for (EntityMap::value_type& data : m_Units) data.second.cmpUnitMotion->OnTurnStart(); } void CCmpUnitMotionManager::MoveUnits(fixed dt) { Move(m_Units, dt); } void CCmpUnitMotionManager::MoveFormations(fixed dt) { Move(m_FormationControllers, dt); } void CCmpUnitMotionManager::Move(EntityMap& ents, fixed dt) { PROFILE2("MotionMgr_Move"); std::unordered_set::iterator>*> assigned; for (EntityMap::iterator it = ents.begin(); it != ents.end(); ++it) { if (!it->second.cmpPosition->IsInWorld()) { it->second.needUpdate = false; continue; } else it->second.cmpUnitMotion->PreMove(it->second); it->second.initialPos = it->second.cmpPosition->GetPosition2D(); it->second.initialAngle = it->second.cmpPosition->GetRotation().Y; it->second.pos = it->second.initialPos; + it->second.speed = it->second.cmpUnitMotion->GetCurrentSpeed(); it->second.angle = it->second.initialAngle; ENSURE(it->second.pos.X.ToInt_RoundToZero() / PUSHING_GRID_SIZE < m_MovingUnits.width() && it->second.pos.Y.ToInt_RoundToZero() / PUSHING_GRID_SIZE < m_MovingUnits.height()); std::vector::iterator>& subdiv = m_MovingUnits.get( it->second.pos.X.ToInt_RoundToZero() / PUSHING_GRID_SIZE, it->second.pos.Y.ToInt_RoundToZero() / PUSHING_GRID_SIZE ); subdiv.emplace_back(it); assigned.emplace(&subdiv); } for (std::vector::iterator>* vec : assigned) for (EntityMap::iterator& it : *vec) if (it->second.needUpdate) it->second.cmpUnitMotion->Move(it->second, dt); // Skip pushing entirely if the radius is 0 if (&ents == &m_Units && m_PushingRadius != entity_pos_t::Zero()) { PROFILE2("MotionMgr_Pushing"); for (std::vector::iterator>* vec : assigned) { ENSURE(!vec->empty()); std::vector::iterator>::iterator cit1 = vec->begin(); do { if ((*cit1)->second.ignore) continue; std::vector::iterator>::iterator cit2 = cit1; while(++cit2 != vec->end()) if (!(*cit2)->second.ignore) Push(**cit1, **cit2, dt); } while(++cit1 != vec->end()); } } if (m_PushingRadius != entity_pos_t::Zero()) { PROFILE2("MotionMgr_PushAdjust"); CmpPtr cmpPathfinder(GetSystemEntity()); for (std::vector::iterator>* vec : assigned) { for (EntityMap::iterator& it : *vec) { if (!it->second.needUpdate || it->second.ignore) continue; // Prevent pushed units from crossing uncrossable boundaries // (we can assume that normal movement didn't push units into impassable terrain). if ((it->second.push.X != entity_pos_t::Zero() || it->second.push.Y != entity_pos_t::Zero()) && !cmpPathfinder->CheckMovement(it->second.cmpUnitMotion->GetObstructionFilter(), it->second.pos.X, it->second.pos.Y, it->second.pos.X + it->second.push.X, it->second.pos.Y + it->second.push.Y, it->second.cmpUnitMotion->m_Clearance, it->second.cmpUnitMotion->m_PassClass)) { // Mark them as obstructed - this could possibly be optimised // perhaps it'd make more sense to mark the pushers as blocked. it->second.wasObstructed = true; it->second.wentStraight = false; it->second.push = CFixedVector2D(); } // Only apply pushing if the effect is significant enough. if (it->second.push.CompareLength(m_MinimalPushing) > 0) { // If there was an attempt at movement, and the pushed movement is in a sufficiently different direction // (measured by an extremely arbitrary dot product) // then mark the unit as obstructed still. if (it->second.pos != it->second.initialPos && (it->second.pos - it->second.initialPos).Dot(it->second.pos + it->second.push - it->second.initialPos) < entity_pos_t::FromInt(1)/2) { it->second.wasObstructed = true; it->second.wentStraight = false; // Push anyways. } it->second.pos += it->second.push; } it->second.push = CFixedVector2D(); } } } { PROFILE2("MotionMgr_PostMove"); for (EntityMap::value_type& data : ents) { if (!data.second.needUpdate) continue; data.second.cmpUnitMotion->PostMove(data.second, dt); } } for (std::vector::iterator>* vec : assigned) vec->clear(); } // TODO: ought to better simulate in-flight pushing, e.g. if units would cross in-between turns. void CCmpUnitMotionManager::Push(EntityMap::value_type& a, EntityMap::value_type& b, fixed dt) { // The hard problem for pushing is knowing when to actually use the pathfinder to go around unpushable obstacles. // For simplicitly, the current logic separates moving & stopped entities: // moving entities will push moving entities, but not stopped ones, and vice-versa. // this still delivers most of the value of pushing, without a lot of the complexity. int movingPush = a.second.isMoving + b.second.isMoving; // Exception: units in the same control group (i.e. the same formation) never push farther than themselves // and are also allowed to push idle units (obstructions are ignored within formations, // so pushing idle units makes one member crossing the formation look better). bool sameControlGroup = a.second.controlGroup != INVALID_ENTITY && a.second.controlGroup == b.second.controlGroup; if (sameControlGroup) movingPush = 0; if (movingPush == 1) return; entity_pos_t combinedClearance = (a.second.cmpUnitMotion->m_Clearance + b.second.cmpUnitMotion->m_Clearance).Multiply(PUSHING_CORRECTION); entity_pos_t maxDist = combinedClearance; if (!sameControlGroup) maxDist = combinedClearance.Multiply(m_PushingRadius) + (movingPush ? m_MovingPushExtension : m_StaticPushExtension); CFixedVector2D offset = a.second.pos - b.second.pos; if (offset.CompareLength(maxDist) > 0) return; entity_pos_t offsetLength = offset.Length(); // If the offset is small enough that precision would be problematic, pick an arbitrary vector instead. if (offsetLength <= entity_pos_t::Epsilon() * 10) { // Throw in some 'randomness' so that clumped units unclump more naturally. bool dir = a.first % 2; offset.X = entity_pos_t::FromInt(dir ? 1 : 0); offset.Y = entity_pos_t::FromInt(dir ? 0 : 1); offsetLength = entity_pos_t::Epsilon() * 10; } else { offset.X = offset.X / offsetLength; offset.Y = offset.Y / offsetLength; } // If the units are moving in opposite direction, check if they might have phased through each other. // If it looks like yes, move them perpendicularily so it looks like they avoid each other. // NB: this isn't very precise, nor will it catch 100% of intersections - it's meant as a cheap improvement. if (movingPush && (a.second.pos - a.second.initialPos).Dot(b.second.pos - b.second.initialPos) < entity_pos_t::Zero()) // Perform some finer checking. if (Geometry::TestRayAASquare(a.second.initialPos - b.second.initialPos, a.second.pos - b.second.initialPos, CFixedVector2D(combinedClearance, combinedClearance)) || Geometry::TestRayAASquare(a.second.initialPos - b.second.pos, a.second.pos - b.second.pos, CFixedVector2D(combinedClearance, combinedClearance))) { offset = offset.Perpendicular(); offsetLength = fixed::Zero(); } // The pushing distance factor is 1 if the edges are touching, >1 up to MAX if the units overlap, < 1 otherwise. entity_pos_t distanceFactor = maxDist - combinedClearance; // Force units that overlap a lot to have the maximum factor. if (distanceFactor <= entity_pos_t::Zero() || offsetLength < combinedClearance / 2) distanceFactor = MAX_DISTANCE_FACTOR; else distanceFactor = Clamp((maxDist - offsetLength) / distanceFactor, entity_pos_t::Zero(), MAX_DISTANCE_FACTOR); // Mark both as needing an update so they actually get moved. a.second.needUpdate = true; b.second.needUpdate = true; CFixedVector2D pushingDir = offset.Multiply(distanceFactor); // Divide by an arbitrary constant to avoid pushing too much. a.second.push += pushingDir.Multiply(dt / PUSHING_REDUCTION_FACTOR); b.second.push -= pushingDir.Multiply(dt / PUSHING_REDUCTION_FACTOR); } Index: ps/trunk/source/simulation2/components/CCmpUnitMotionManager.h =================================================================== --- ps/trunk/source/simulation2/components/CCmpUnitMotionManager.h (revision 25952) +++ ps/trunk/source/simulation2/components/CCmpUnitMotionManager.h (revision 25953) @@ -1,197 +1,199 @@ /* Copyright (C) 2021 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_CCMPUNITMOTIONMANAGER #define INCLUDED_CCMPUNITMOTIONMANAGER #include "simulation2/system/Component.h" #include "ICmpUnitMotionManager.h" #include "simulation2/MessageTypes.h" #include "simulation2/components/ICmpTerrain.h" #include "simulation2/helpers/Grid.h" #include "simulation2/system/EntityMap.h" class CCmpUnitMotion; class CCmpUnitMotionManager : public ICmpUnitMotionManager { public: static void ClassInit(CComponentManager& componentManager) { componentManager.SubscribeToMessageType(MT_TerrainChanged); componentManager.SubscribeToMessageType(MT_TurnStart); componentManager.SubscribeToMessageType(MT_Update_Final); componentManager.SubscribeToMessageType(MT_Update_MotionUnit); componentManager.SubscribeToMessageType(MT_Update_MotionFormation); } DEFAULT_COMPONENT_ALLOCATOR(UnitMotionManager) // Persisted state for each unit. struct MotionState { MotionState(CmpPtr cmpPos, CCmpUnitMotion* cmpMotion); // Component references - these must be kept alive for the duration of motion. // NB: this is generally not something one should do, but because of the tight coupling here it's doable. CmpPtr cmpPosition; CCmpUnitMotion* cmpUnitMotion; // Position before units start moving CFixedVector2D initialPos; // Transient position during the movement. CFixedVector2D pos; // Accumulated "pushing" from nearby units. CFixedVector2D push; + fixed speed; + fixed initialAngle; fixed angle; // Used for formations - units with the same control group won't push at a distance. // (this is required because formations may be tight and large units may end up never settling. entity_id_t controlGroup = INVALID_ENTITY; // Meta-flag -> this entity won't push nor be pushed. // (used for entities that have their obstruction disabled). bool ignore = false; // If true, the entity needs to be handled during movement. bool needUpdate = false; bool wentStraight = false; bool wasObstructed = false; // Clone of the obstruction manager flag for efficiency bool isMoving = false; }; // "Template" state, not serialized (cannot be changed mid-game). // Multiplier for the pushing radius. Pre-multiplied by the circle-square correction factor. entity_pos_t m_PushingRadius; // Additive modifiers to the pushing radius for moving units and idle units respectively. entity_pos_t m_MovingPushExtension; entity_pos_t m_StaticPushExtension; // Pushing forces below this value are ignored - this prevents units moving forever by very small increments. entity_pos_t m_MinimalPushing; // These vectors are reconstructed on deserialization. EntityMap m_Units; EntityMap m_FormationControllers; // Turn-local state below, not serialised. Grid::iterator>> m_MovingUnits; bool m_ComputingMotion; static std::string GetSchema() { return ""; } virtual void Init(const CParamNode& UNUSED(paramNode)); virtual void Deinit() { } virtual void Serialize(ISerializer& UNUSED(serialize)) { } virtual void Deserialize(const CParamNode& paramNode, IDeserializer& UNUSED(deserialize)) { Init(paramNode); ResetSubdivisions(); } virtual void HandleMessage(const CMessage& msg, bool UNUSED(global)) { switch (msg.GetType()) { case MT_TerrainChanged: { CmpPtr cmpTerrain(GetSystemEntity()); if (cmpTerrain->GetVerticesPerSide() != m_MovingUnits.width()) ResetSubdivisions(); break; } case MT_TurnStart: { OnTurnStart(); break; } case MT_Update_MotionFormation: { fixed dt = static_cast(msg).turnLength; m_ComputingMotion = true; MoveFormations(dt); m_ComputingMotion = false; break; } case MT_Update_MotionUnit: { fixed dt = static_cast(msg).turnLength; m_ComputingMotion = true; MoveUnits(dt); m_ComputingMotion = false; break; } } } virtual void Register(CCmpUnitMotion* component, entity_id_t ent, bool formationController); virtual void Unregister(entity_id_t ent); virtual bool ComputingMotion() const { return m_ComputingMotion; } virtual bool IsPushingActivated() const { return m_PushingRadius != entity_pos_t::Zero(); } private: void ResetSubdivisions(); void OnTurnStart(); void MoveUnits(fixed dt); void MoveFormations(fixed dt); void Move(EntityMap& ents, fixed dt); void Push(EntityMap::value_type& a, EntityMap::value_type& b, fixed dt); }; void CCmpUnitMotionManager::ResetSubdivisions() { CmpPtr cmpTerrain(GetSystemEntity()); if (!cmpTerrain) return; size_t size = cmpTerrain->GetMapSize(); u16 gridSquareSize = static_cast(size / 20 + 1); m_MovingUnits.resize(gridSquareSize, gridSquareSize); } REGISTER_COMPONENT_TYPE(UnitMotionManager) #endif // INCLUDED_CCMPUNITMOTIONMANAGER Index: ps/trunk/source/simulation2/components/CCmpUnitMotion.h =================================================================== --- ps/trunk/source/simulation2/components/CCmpUnitMotion.h (revision 25952) +++ ps/trunk/source/simulation2/components/CCmpUnitMotion.h (revision 25953) @@ -1,1821 +1,1877 @@ /* Copyright (C) 2021 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_CCMPUNITMOTION #define INCLUDED_CCMPUNITMOTION #include "simulation2/system/Component.h" #include "ICmpUnitMotion.h" #include "simulation2/components/CCmpUnitMotionManager.h" #include "simulation2/components/ICmpObstruction.h" #include "simulation2/components/ICmpObstructionManager.h" #include "simulation2/components/ICmpOwnership.h" #include "simulation2/components/ICmpPosition.h" #include "simulation2/components/ICmpPathfinder.h" #include "simulation2/components/ICmpRangeManager.h" #include "simulation2/components/ICmpValueModificationManager.h" #include "simulation2/components/ICmpVisual.h" #include "simulation2/helpers/Geometry.h" #include "simulation2/helpers/Render.h" #include "simulation2/MessageTypes.h" #include "simulation2/serialization/SerializedPathfinder.h" #include "simulation2/serialization/SerializedTypes.h" #include "graphics/Overlay.h" #include "maths/FixedVector2D.h" #include "ps/CLogger.h" #include "ps/Profile.h" #include "renderer/Scene.h" // NB: this implementation of ICmpUnitMotion is very tightly coupled with UnitMotionManager. // As such, both are compiled in the same TU. // For debugging; units will start going straight to the target // instead of calling the pathfinder #define DISABLE_PATHFINDER 0 namespace { /** * Min/Max range to restrict short path queries to. (Larger ranges are (much) slower, * smaller ranges might miss some legitimate routes around large obstacles.) * NB: keep the max-range in sync with the vertex pathfinder "move the search space" heuristic. */ constexpr entity_pos_t SHORT_PATH_MIN_SEARCH_RANGE = entity_pos_t::FromInt(12 * Pathfinding::NAVCELL_SIZE_INT); constexpr entity_pos_t SHORT_PATH_MAX_SEARCH_RANGE = entity_pos_t::FromInt(56 * Pathfinding::NAVCELL_SIZE_INT); constexpr entity_pos_t SHORT_PATH_SEARCH_RANGE_INCREMENT = entity_pos_t::FromInt(4 * Pathfinding::NAVCELL_SIZE_INT); constexpr u8 SHORT_PATH_SEARCH_RANGE_INCREASE_DELAY = 1; /** * When using the short-pathfinder to rejoin a long-path waypoint, aim for a circle of this radius around the waypoint. */ constexpr entity_pos_t SHORT_PATH_LONG_WAYPOINT_RANGE = entity_pos_t::FromInt(4 * Pathfinding::NAVCELL_SIZE_INT); /** * Minimum distance to goal for a long path request */ constexpr entity_pos_t LONG_PATH_MIN_DIST = entity_pos_t::FromInt(16 * Pathfinding::NAVCELL_SIZE_INT); /** * If we are this close to our target entity/point, then think about heading * for it in a straight line instead of pathfinding. */ constexpr entity_pos_t DIRECT_PATH_RANGE = entity_pos_t::FromInt(24 * Pathfinding::NAVCELL_SIZE_INT); /** * To avoid recomputing paths too often, have some leeway for target range checks * based on our distance to the target. Increase that incertainty by one navcell * for every this many tiles of distance. */ constexpr entity_pos_t TARGET_UNCERTAINTY_MULTIPLIER = entity_pos_t::FromInt(8 * Pathfinding::NAVCELL_SIZE_INT); /** * When following a known imperfect path (i.e. a path that won't take us in range of our goal * we still recompute a new path every N turn to adapt to moving targets (for example, ships that must pickup * units may easily end up in this state, they still need to adjust to moving units). * This is rather arbitrary and mostly for simplicity & optimisation (a better recomputing algorithm * would not need this). */ constexpr u8 KNOWN_IMPERFECT_PATH_RESET_COUNTDOWN = 12; /** * When we fail to move this many turns in a row, inform other components that the move will fail. * Experimentally, this number needs to be somewhat high or moving groups of units will lead to stuck units. * However, too high means units will look idle for a long time when they are failing to move. * TODO: if UnitMotion could send differentiated "unreachable" and "currently stuck" failing messages, * this could probably be lowered. * TODO: when unit pushing is implemented, this number can probably be lowered. */ constexpr u8 MAX_FAILED_MOVEMENTS = 35; /** * When computing paths but failing to move, we want to occasionally alternate pathfinder systems * to avoid getting stuck (the short pathfinder can unstuck the long-range one and vice-versa, depending). */ constexpr u8 ALTERNATE_PATH_TYPE_DELAY = 3; constexpr u8 ALTERNATE_PATH_TYPE_EVERY = 6; /** * Units can occasionally get stuck near corners. The cause is a mismatch between CheckMovement and the short pathfinder. * The problem is the short pathfinder finds an impassable path when units are right on an obstruction edge. * Fixing this math mismatch is perhaps possible, but fixing it in UM is rather easy: just try backing up a bit * and that will probably un-stuck the unit. This is the 'failed movement' turn on which to try that. */ constexpr u8 BACKUP_HACK_DELAY = 10; /** * After this many failed computations, start sending "VERY_OBSTRUCTED" messages instead. * Should probably be larger than ALTERNATE_PATH_TYPE_DELAY. */ constexpr u8 VERY_OBSTRUCTED_THRESHOLD = 10; const CColor OVERLAY_COLOR_LONG_PATH(1, 1, 1, 1); const CColor OVERLAY_COLOR_SHORT_PATH(1, 0, 0, 1); } // anonymous namespace class CCmpUnitMotion final : public ICmpUnitMotion { friend class CCmpUnitMotionManager; public: static void ClassInit(CComponentManager& componentManager) { componentManager.SubscribeToMessageType(MT_Create); componentManager.SubscribeToMessageType(MT_Destroy); componentManager.SubscribeToMessageType(MT_PathResult); componentManager.SubscribeToMessageType(MT_OwnershipChanged); componentManager.SubscribeToMessageType(MT_ValueModification); componentManager.SubscribeToMessageType(MT_MovementObstructionChanged); componentManager.SubscribeToMessageType(MT_Deserialized); } DEFAULT_COMPONENT_ALLOCATOR(UnitMotion) bool m_DebugOverlayEnabled; std::vector m_DebugOverlayLongPathLines; std::vector m_DebugOverlayShortPathLines; // Template state: bool m_IsFormationController; - fixed m_TemplateWalkSpeed, m_TemplateRunMultiplier; + fixed m_TemplateWalkSpeed, m_TemplateRunMultiplier, m_TemplateAcceleration; pass_class_t m_PassClass; std::string m_PassClassName; // Dynamic state: entity_pos_t m_Clearance; // cached for efficiency fixed m_WalkSpeed, m_RunMultiplier; bool m_FacePointAfterMove; // Whether the unit participates in pushing. bool m_Pushing = false; // Whether the unit blocks movement (& is blocked by movement blockers) // Cached from ICmpObstruction. bool m_BlockMovement = false; // Internal counter used when recovering from obstructed movement. // Most notably, increases the search range of the vertex pathfinder. // See HandleObstructedMove() for more details. u8 m_FailedMovements = 0; // If > 0, PathingUpdateNeeded returns false always. // This exists because the goal may be unreachable to the short/long pathfinder. // In such cases, we would compute inacceptable paths and PathingUpdateNeeded would trigger every turn, // which would be quite bad for performance. // To avoid that, when we know the new path is imperfect, treat it as OK and follow it anyways. // When reaching the end, we'll go through HandleObstructedMove and reset regardless. // To still recompute now and then (the target may be moving), this is a countdown decremented on each frame. u8 m_FollowKnownImperfectPathCountdown = 0; struct Ticket { u32 m_Ticket = 0; // asynchronous request ID we're waiting for, or 0 if none enum Type { SHORT_PATH, LONG_PATH } m_Type = SHORT_PATH; // Pick some default value to avoid UB. void clear() { m_Ticket = 0; } } m_ExpectedPathTicket; struct MoveRequest { enum Type { NONE, POINT, ENTITY, OFFSET } m_Type = NONE; entity_id_t m_Entity = INVALID_ENTITY; CFixedVector2D m_Position; entity_pos_t m_MinRange, m_MaxRange; // For readability CFixedVector2D GetOffset() const { return m_Position; }; MoveRequest() = default; MoveRequest(CFixedVector2D pos, entity_pos_t minRange, entity_pos_t maxRange) : m_Type(POINT), m_Position(pos), m_MinRange(minRange), m_MaxRange(maxRange) {}; MoveRequest(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) : m_Type(ENTITY), m_Entity(target), m_MinRange(minRange), m_MaxRange(maxRange) {}; MoveRequest(entity_id_t target, CFixedVector2D offset) : m_Type(OFFSET), m_Entity(target), m_Position(offset) {}; } m_MoveRequest; // If this is not INVALID_ENTITY, the unit is a formation member. entity_id_t m_FormationController = INVALID_ENTITY; // If the entity moves, it will do so at m_WalkSpeed * m_SpeedMultiplier. fixed m_SpeedMultiplier; // This caches the resulting speed from m_WalkSpeed * m_SpeedMultiplier for convenience. fixed m_Speed; - // Current mean speed (over the last turn). - fixed m_CurSpeed; + // The speed achieved at the end of the current turn. + fixed m_CurrentSpeed; + + fixed m_InstantTurnAngle; + + fixed m_Acceleration; // Currently active paths (storing waypoints in reverse order). // The last item in each path is the point we're currently heading towards. WaypointPath m_LongPath; WaypointPath m_ShortPath; static std::string GetSchema() { return "Provides the unit with the ability to move around the world by itself." "" "7.0" "default" "" "" "" "" - "" + "" "" "" "" - "" + "" "" "" "" - "" + "" + "" + "" + "" + "" + "" + "" "" "" "" "" "" "" ""; } virtual void Init(const CParamNode& paramNode) { m_IsFormationController = paramNode.GetChild("FormationController").ToBool(); m_FacePointAfterMove = true; m_WalkSpeed = m_TemplateWalkSpeed = m_Speed = paramNode.GetChild("WalkSpeed").ToFixed(); m_SpeedMultiplier = fixed::FromInt(1); - m_CurSpeed = fixed::Zero(); + m_CurrentSpeed = fixed::Zero(); m_RunMultiplier = m_TemplateRunMultiplier = fixed::FromInt(1); if (paramNode.GetChild("RunMultiplier").IsOk()) m_RunMultiplier = m_TemplateRunMultiplier = paramNode.GetChild("RunMultiplier").ToFixed(); + m_InstantTurnAngle = paramNode.GetChild("InstantTurnAngle").ToFixed(); + + m_Acceleration = m_TemplateAcceleration = paramNode.GetChild("Acceleration").ToFixed(); + CmpPtr cmpPathfinder(GetSystemEntity()); if (cmpPathfinder) { m_PassClassName = paramNode.GetChild("PassabilityClass").ToString(); m_PassClass = cmpPathfinder->GetPassabilityClass(m_PassClassName); m_Clearance = cmpPathfinder->GetClearance(m_PassClass); CmpPtr cmpObstruction(GetEntityHandle()); if (cmpObstruction) { cmpObstruction->SetUnitClearance(m_Clearance); m_BlockMovement = cmpObstruction->GetBlockMovementFlag(true); } } SetParticipateInPushing(!paramNode.GetChild("DisablePushing").IsOk() || !paramNode.GetChild("DisablePushing").ToBool()); m_DebugOverlayEnabled = false; } virtual void Deinit() { } template void SerializeCommon(S& serialize) { serialize.StringASCII("pass class", m_PassClassName, 0, 64); serialize.NumberU32_Unbounded("ticket", m_ExpectedPathTicket.m_Ticket); Serializer(serialize, "ticket type", m_ExpectedPathTicket.m_Type, Ticket::Type::LONG_PATH); serialize.NumberU8_Unbounded("failed movements", m_FailedMovements); serialize.NumberU8_Unbounded("followknownimperfectpath", m_FollowKnownImperfectPathCountdown); Serializer(serialize, "target type", m_MoveRequest.m_Type, MoveRequest::Type::OFFSET); serialize.NumberU32_Unbounded("target entity", m_MoveRequest.m_Entity); serialize.NumberFixed_Unbounded("target pos x", m_MoveRequest.m_Position.X); serialize.NumberFixed_Unbounded("target pos y", m_MoveRequest.m_Position.Y); serialize.NumberFixed_Unbounded("target min range", m_MoveRequest.m_MinRange); serialize.NumberFixed_Unbounded("target max range", m_MoveRequest.m_MaxRange); serialize.NumberU32_Unbounded("formation controller", m_FormationController); serialize.NumberFixed_Unbounded("speed multiplier", m_SpeedMultiplier); - serialize.NumberFixed_Unbounded("current speed", m_CurSpeed); + serialize.NumberFixed_Unbounded("current speed", m_CurrentSpeed); + + serialize.NumberFixed_Unbounded("instant turn angle", m_InstantTurnAngle); + + serialize.NumberFixed_Unbounded("acceleration", m_Acceleration); serialize.Bool("facePointAfterMove", m_FacePointAfterMove); serialize.Bool("pushing", m_Pushing); Serializer(serialize, "long path", m_LongPath.m_Waypoints); Serializer(serialize, "short path", m_ShortPath.m_Waypoints); } virtual void Serialize(ISerializer& serialize) { SerializeCommon(serialize); } virtual void Deserialize(const CParamNode& paramNode, IDeserializer& deserialize) { Init(paramNode); SerializeCommon(deserialize); CmpPtr cmpPathfinder(GetSystemEntity()); if (cmpPathfinder) m_PassClass = cmpPathfinder->GetPassabilityClass(m_PassClassName); CmpPtr cmpObstruction(GetEntityHandle()); if (cmpObstruction) m_BlockMovement = cmpObstruction->GetBlockMovementFlag(false); } virtual void HandleMessage(const CMessage& msg, bool UNUSED(global)) { switch (msg.GetType()) { 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_Create: { if (!ENTITY_IS_LOCAL(GetEntityId())) CmpPtr(GetSystemEntity())->Register(this, GetEntityId(), m_IsFormationController); break; } case MT_Destroy: { if (!ENTITY_IS_LOCAL(GetEntityId())) CmpPtr(GetSystemEntity())->Unregister(GetEntityId()); break; } case MT_MovementObstructionChanged: { CmpPtr cmpObstruction(GetEntityHandle()); if (cmpObstruction) m_BlockMovement = cmpObstruction->GetBlockMovementFlag(false); break; } case MT_ValueModification: { const CMessageValueModification& msgData = static_cast (msg); if (msgData.component != L"UnitMotion") break; FALLTHROUGH; } case MT_OwnershipChanged: { OnValueModification(); break; } case MT_Deserialized: { OnValueModification(); if (!ENTITY_IS_LOCAL(GetEntityId())) CmpPtr(GetSystemEntity())->Register(this, GetEntityId(), m_IsFormationController); break; } } } void UpdateMessageSubscriptions() { bool needRender = m_DebugOverlayEnabled; GetSimContext().GetComponentManager().DynamicSubscriptionNonsync(MT_RenderSubmit, this, needRender); } virtual bool IsMoveRequested() const { return m_MoveRequest.m_Type != MoveRequest::NONE; } virtual fixed GetSpeedMultiplier() const { return m_SpeedMultiplier; } virtual void SetSpeedMultiplier(fixed multiplier) { m_SpeedMultiplier = std::min(multiplier, m_RunMultiplier); m_Speed = m_SpeedMultiplier.Multiply(GetWalkSpeed()); } virtual fixed GetSpeed() const { return m_Speed; } virtual fixed GetWalkSpeed() const { return m_WalkSpeed; } virtual fixed GetRunMultiplier() const { return m_RunMultiplier; } virtual CFixedVector2D EstimateFuturePosition(const fixed dt) const { CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return CFixedVector2D(); // TODO: formation members should perhaps try to use the controller's position. CFixedVector2D pos = cmpPosition->GetPosition2D(); entity_angle_t angle = cmpPosition->GetRotation().Y; - + fixed speed = m_CurrentSpeed; // Copy the path so we don't change it. WaypointPath shortPath = m_ShortPath; WaypointPath longPath = m_LongPath; - PerformMove(dt, cmpPosition->GetTurnRate(), shortPath, longPath, pos, angle); + PerformMove(dt, cmpPosition->GetTurnRate(), shortPath, longPath, pos, speed, angle); return pos; } + virtual fixed GetAcceleration() const + { + return m_Acceleration; + } + + virtual void SetAcceleration(fixed acceleration) + { + m_Acceleration = acceleration; + } + 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; + return m_CurrentSpeed; } virtual void SetFacePointAfterMove(bool facePointAfterMove) { m_FacePointAfterMove = facePointAfterMove; } virtual bool GetFacePointAfterMove() const { return m_FacePointAfterMove; } virtual void SetDebugOverlay(bool enabled) { m_DebugOverlayEnabled = enabled; UpdateMessageSubscriptions(); } virtual bool MoveToPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t minRange, entity_pos_t maxRange) { return MoveTo(MoveRequest(CFixedVector2D(x, z), minRange, maxRange)); } virtual bool MoveToTargetRange(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) { return MoveTo(MoveRequest(target, minRange, maxRange)); } virtual void MoveToFormationOffset(entity_id_t controller, entity_pos_t x, entity_pos_t z) { // Pass the controller to the move request anyways. MoveTo(MoveRequest(controller, CFixedVector2D(x, z))); } virtual void SetMemberOfFormation(entity_id_t controller) { m_FormationController = controller; } virtual bool IsTargetRangeReachable(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange); virtual void FaceTowardsPoint(entity_pos_t x, entity_pos_t z); /** * Clears the current MoveRequest - the unit will stop and no longer try and move. * This should never be called from UnitMotion, since MoveToX orders are given * by other components - these components should also decide when to stop. */ virtual void StopMoving() { if (m_FacePointAfterMove) { CmpPtr cmpPosition(GetEntityHandle()); if (cmpPosition && cmpPosition->IsInWorld()) { CFixedVector2D targetPos; if (ComputeTargetPosition(targetPos)) FaceTowardsPointFromPos(cmpPosition->GetPosition2D(), targetPos.X, targetPos.Y); } } m_MoveRequest = MoveRequest(); m_ExpectedPathTicket.clear(); m_LongPath.m_Waypoints.clear(); m_ShortPath.m_Waypoints.clear(); } virtual entity_pos_t GetUnitClearance() const { return m_Clearance; } private: bool IsFormationMember() const { return m_FormationController != INVALID_ENTITY; } bool IsMovingAsFormation() const { return IsFormationMember() && m_MoveRequest.m_Type == MoveRequest::OFFSET; } bool IsFormationControllerMoving() const { CmpPtr cmpControllerMotion(GetSimContext(), m_FormationController); return cmpControllerMotion && cmpControllerMotion->IsMoveRequested(); } entity_id_t GetGroup() const { return IsFormationMember() ? m_FormationController : GetEntityId(); } void SetParticipateInPushing(bool pushing) { CmpPtr cmpUnitMotionManager(GetSystemEntity()); m_Pushing = pushing && cmpUnitMotionManager->IsPushingActivated(); } /** * Warns other components that our current movement will likely fail (e.g. we won't be able to reach our target) * This should only be called before the actual movement in a given turn, or units might both move and try to do things * on the same turn, leading to gliding units. */ void MoveFailed() { // Don't notify if we are a formation member in a moving formation - we can occasionally be stuck for a long time // if our current offset is unreachable, but we don't want to end up stuck. // (If the formation controller has stopped moving however, we can safely message). if (IsFormationMember() && IsFormationControllerMoving()) return; CMessageMotionUpdate msg(CMessageMotionUpdate::LIKELY_FAILURE); GetSimContext().GetComponentManager().PostMessage(GetEntityId(), msg); } /** * Warns other components that our current movement is likely over (i.e. we probably reached our destination) * This should only be called before the actual movement in a given turn, or units might both move and try to do things * on the same turn, leading to gliding units. */ void MoveSucceeded() { // Don't notify if we are a formation member in a moving formation - we can occasionally be stuck for a long time // if our current offset is unreachable, but we don't want to end up stuck. // (If the formation controller has stopped moving however, we can safely message). if (IsFormationMember() && IsFormationControllerMoving()) return; CMessageMotionUpdate msg(CMessageMotionUpdate::LIKELY_SUCCESS); GetSimContext().GetComponentManager().PostMessage(GetEntityId(), msg); } /** * Warns other components that our current movement was obstructed (i.e. we failed to move this turn). * This should only be called before the actual movement in a given turn, or units might both move and try to do things * on the same turn, leading to gliding units. */ void MoveObstructed() { // Don't notify if we are a formation member in a moving formation - we can occasionally be stuck for a long time // if our current offset is unreachable, but we don't want to end up stuck. // (If the formation controller has stopped moving however, we can safely message). if (IsFormationMember() && IsFormationControllerMoving()) return; CMessageMotionUpdate msg(m_FailedMovements >= VERY_OBSTRUCTED_THRESHOLD ? CMessageMotionUpdate::VERY_OBSTRUCTED : CMessageMotionUpdate::OBSTRUCTED); GetSimContext().GetComponentManager().PostMessage(GetEntityId(), msg); } /** * Increment the number of failed movements and notify other components if required. * @returns true if the failure was notified, false otherwise. */ bool IncrementFailedMovementsAndMaybeNotify() { m_FailedMovements++; if (m_FailedMovements >= MAX_FAILED_MOVEMENTS) { MoveFailed(); m_FailedMovements = 0; return true; } return false; } /** * If path would take us farther away from the goal than pos currently is, return false, else return true. */ bool RejectFartherPaths(const PathGoal& goal, const WaypointPath& path, const CFixedVector2D& pos) const; bool ShouldAlternatePathfinder() const { return (m_FailedMovements == ALTERNATE_PATH_TYPE_DELAY) || ((MAX_FAILED_MOVEMENTS - ALTERNATE_PATH_TYPE_DELAY) % ALTERNATE_PATH_TYPE_EVERY == 0); } bool InShortPathRange(const PathGoal& goal, const CFixedVector2D& pos) const { return goal.DistanceToPoint(pos) < LONG_PATH_MIN_DIST; } entity_pos_t ShortPathSearchRange() const { u8 multiple = m_FailedMovements < SHORT_PATH_SEARCH_RANGE_INCREASE_DELAY ? 0 : m_FailedMovements - SHORT_PATH_SEARCH_RANGE_INCREASE_DELAY; fixed searchRange = SHORT_PATH_MIN_SEARCH_RANGE + SHORT_PATH_SEARCH_RANGE_INCREMENT * multiple; if (searchRange > SHORT_PATH_MAX_SEARCH_RANGE) searchRange = SHORT_PATH_MAX_SEARCH_RANGE; return searchRange; } /** * Handle the result of an asynchronous path query. */ void PathResult(u32 ticket, const WaypointPath& path); void OnValueModification() { CmpPtr cmpValueModificationManager(GetSystemEntity()); if (!cmpValueModificationManager) return; m_WalkSpeed = cmpValueModificationManager->ApplyModifications(L"UnitMotion/WalkSpeed", m_TemplateWalkSpeed, GetEntityId()); m_RunMultiplier = cmpValueModificationManager->ApplyModifications(L"UnitMotion/RunMultiplier", m_TemplateRunMultiplier, GetEntityId()); // For MT_Deserialize compute m_Speed from the serialized m_SpeedMultiplier. // For MT_ValueModification and MT_OwnershipChanged, adjust m_SpeedMultiplier if needed // (in case then new m_RunMultiplier value is lower than the old). SetSpeedMultiplier(m_SpeedMultiplier); } /** * Check if we are at destination early in the turn, this both lets units react faster * and ensure that distance comparisons are done while units are not being moved * (otherwise they won't be commutative). */ void OnTurnStart(); void PreMove(CCmpUnitMotionManager::MotionState& state); void Move(CCmpUnitMotionManager::MotionState& state, fixed dt); void PostMove(CCmpUnitMotionManager::MotionState& state, fixed dt); /** * Returns true if we are possibly at our destination. * Since the concept of being at destination is dependent on why the move was requested, * UnitMotion can only ever hint about this, hence the conditional tone. */ bool PossiblyAtDestination() const; /** * Process the move the unit will do this turn. * This does not send actually change the position. * @returns true if the move was obstructed. */ - bool PerformMove(fixed dt, const fixed& turnRate, WaypointPath& shortPath, WaypointPath& longPath, CFixedVector2D& pos, entity_angle_t& angle) const; + bool PerformMove(fixed dt, const fixed& turnRate, WaypointPath& shortPath, WaypointPath& longPath, CFixedVector2D& pos, fixed& speed, entity_angle_t& angle) const; /** * Update other components on our speed. * (For performance, this should try to avoid sending messages). */ - void UpdateMovementState(entity_pos_t speed); + void UpdateMovementState(entity_pos_t speed, entity_pos_t meanSpeed); /** * React if our move was obstructed. * @param moved - true if the unit still managed to move. * @returns true if the obstruction required handling, false otherwise. */ bool HandleObstructedMove(bool moved); /** * Returns true if the target position is valid. False otherwise. * (this may indicate that the target is e.g. out of the world/dead). * NB: for code-writing convenience, if we have no target, this returns true. */ bool TargetHasValidPosition(const MoveRequest& moveRequest) const; bool TargetHasValidPosition() const { return TargetHasValidPosition(m_MoveRequest); } /** * Computes the current location of our target entity (plus offset). * Returns false if no target entity or no valid position. */ bool ComputeTargetPosition(CFixedVector2D& out, const MoveRequest& moveRequest) const; bool ComputeTargetPosition(CFixedVector2D& out) const { return ComputeTargetPosition(out, m_MoveRequest); } /** * Attempts to replace the current path with a straight line to the target, * if it's close enough and the route is not obstructed. */ bool TryGoingStraightToTarget(const CFixedVector2D& from, bool updatePaths); /** * Returns whether our we need to recompute a path to reach our target. */ bool PathingUpdateNeeded(const CFixedVector2D& from) const; /** * Rotate to face towards the target point, given the current pos */ void FaceTowardsPointFromPos(const CFixedVector2D& pos, entity_pos_t x, entity_pos_t z); /** * Units in 'pushing' mode are marked as 'moving' in the obstruction manager. * Units in 'pushing' mode should skip them in checkMovement (to enable pushing). * However, units for which pushing is deactivated should collide against everyone. * Units that don't block movement never participate in pushing, but they also * shouldn't collide with pushing units. */ bool ShouldCollideWithMovingUnits() const { return !m_Pushing && m_BlockMovement; } /** * Returns an appropriate obstruction filter for use with path requests. */ ControlGroupMovementObstructionFilter GetObstructionFilter() const { return ControlGroupMovementObstructionFilter(ShouldCollideWithMovingUnits(), GetGroup()); } /** * Filter a specific tag on top of the existing control groups. */ SkipTagAndControlGroupObstructionFilter GetObstructionFilter(const ICmpObstructionManager::tag_t& tag) const { return SkipTagAndControlGroupObstructionFilter(tag, ShouldCollideWithMovingUnits(), GetGroup()); } /** * Decide whether to approximate the given range from a square target as a circle, * rather than as a square. */ bool ShouldTreatTargetAsCircle(entity_pos_t range, entity_pos_t circleRadius) const; /** * Create a PathGoal from a move request. * @returns true if the goal was successfully created. */ bool ComputeGoal(PathGoal& out, const MoveRequest& moveRequest) const; /** * Compute a path to the given goal from the given position. * Might go in a straight line immediately, or might start an asynchronous path request. */ void ComputePathToGoal(const CFixedVector2D& from, const PathGoal& goal); /** * Start an asynchronous long path query. */ void RequestLongPath(const CFixedVector2D& from, const PathGoal& goal); /** * Start an asynchronous short path query. * @param extendRange - if true, extend the search range to at least the distance to the goal. */ void RequestShortPath(const CFixedVector2D& from, const PathGoal& goal, bool extendRange); /** * General handler for MoveTo interface functions. */ bool MoveTo(MoveRequest request); /** * Convert a path into a renderable list of lines */ void RenderPath(const WaypointPath& path, std::vector& lines, CColor color); void RenderSubmit(SceneCollector& collector); }; REGISTER_COMPONENT_TYPE(UnitMotion) bool CCmpUnitMotion::RejectFartherPaths(const PathGoal& goal, const WaypointPath& path, const CFixedVector2D& pos) const { if (path.m_Waypoints.empty()) return false; // Reject the new path if it does not lead us closer to the target's position. if (goal.DistanceToPoint(pos) <= goal.DistanceToPoint(CFixedVector2D(path.m_Waypoints.front().x, path.m_Waypoints.front().z))) return true; return false; } void CCmpUnitMotion::PathResult(u32 ticket, const WaypointPath& path) { // Ignore obsolete path requests if (ticket != m_ExpectedPathTicket.m_Ticket || m_MoveRequest.m_Type == MoveRequest::NONE) return; Ticket::Type ticketType = m_ExpectedPathTicket.m_Type; m_ExpectedPathTicket.clear(); // If we not longer have a position, we won't be able to do much. // Fail in the next Move() call. CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return; CFixedVector2D pos = cmpPosition->GetPosition2D(); // Assume all long paths were towards the goal, and assume short paths were if there are no long waypoints. bool pathedTowardsGoal = ticketType == Ticket::LONG_PATH || m_LongPath.m_Waypoints.empty(); // Check if we need to run the short-path hack (warning: tricky control flow). bool shortPathHack = false; if (path.m_Waypoints.empty()) { // No waypoints means pathing failed. If this was a long-path, try the short-path hack. if (!pathedTowardsGoal) return; shortPathHack = ticketType == Ticket::LONG_PATH; } else if (PathGoal goal; pathedTowardsGoal && ComputeGoal(goal, m_MoveRequest) && RejectFartherPaths(goal, path, pos)) { // Reject paths that would take the unit further away from the goal. // This assumes that we prefer being closer 'as the crow flies' to unreachable goals. // This is a hack of sorts around units 'dancing' between two positions (see e.g. #3144), // but never actually failing to move, ergo never actually informing unitAI that it succeeds/fails. // (for short paths, only do so if aiming directly for the goal // as sub-goals may be farther than we are). // If this was a long-path and we no longer have waypoints, try the short-path hack. if (!m_LongPath.m_Waypoints.empty()) return; shortPathHack = ticketType == Ticket::LONG_PATH; } // Short-path hack: if the long-range pathfinder doesn't find an acceptable path, push a fake waypoint at the goal. // This means HandleObstructedMove will use the short-pathfinder to try and reach it, // and that may find a path as the vertex pathfinder is more precise. if (shortPathHack) { // If we're resorting to the short-path hack, the situation is dire. Most likely, the goal is unreachable. // We want to find a path or fail fast. Bump failed movements so the short pathfinder will run at max-range // right away. This is safe from a performance PoV because it can only happen if the target is unreachable to // the long-range pathfinder, which is rare, and since the entity will fail to move if the goal is actually unreachable, // the failed movements will be increased to MAX anyways, so just shortcut. m_FailedMovements = MAX_FAILED_MOVEMENTS - 2; CFixedVector2D targetPos; if (ComputeTargetPosition(targetPos)) m_LongPath.m_Waypoints.emplace_back(Waypoint{ targetPos.X, targetPos.Y }); return; } if (ticketType == Ticket::LONG_PATH) { m_LongPath = path; // Long paths don't properly follow diagonals because of JPS/the grid. Since units now take time turning, // they can actually slow down substantially if they have to do a one navcell diagonal movement, // which is somewhat common at the beginning of a new path. // For that reason, if the first waypoint is really close, check if we can't go directly to the second. if (m_LongPath.m_Waypoints.size() >= 2) { const Waypoint& firstWpt = m_LongPath.m_Waypoints.back(); if (CFixedVector2D(firstWpt.x - pos.X, firstWpt.z - pos.Y).CompareLength(Pathfinding::NAVCELL_SIZE * 4) <= 0) { CmpPtr cmpPathfinder(GetSystemEntity()); ENSURE(cmpPathfinder); const Waypoint& secondWpt = m_LongPath.m_Waypoints[m_LongPath.m_Waypoints.size() - 2]; if (cmpPathfinder->CheckMovement(GetObstructionFilter(), pos.X, pos.Y, secondWpt.x, secondWpt.z, m_Clearance, m_PassClass)) m_LongPath.m_Waypoints.pop_back(); } } } else m_ShortPath = path; m_FollowKnownImperfectPathCountdown = 0; if (!pathedTowardsGoal) return; // Performance hack: If we were pathing towards the goal and this new path won't put us in range, // it's highly likely that we are going somewhere unreachable. // However, Move() will try to recompute the path every turn, which can be quite slow. // To avoid this, act as if our current path leads us to the correct destination. // NB: for short-paths, the problem might be that the search space is too small // but we'll still follow this path until the en and try again then. // Because we reject farther paths, it works out. if (PathingUpdateNeeded(pos)) { // Inform other components early, as they might have better behaviour than waiting for the path to carry out. // Send OBSTRUCTED at first - moveFailed is likely to trigger path recomputation and we might end up // recomputing too often for nothing. if (!IncrementFailedMovementsAndMaybeNotify()) MoveObstructed(); // We'll automatically recompute a path when this reaches 0, as a way to improve behaviour. // (See D665 - this is needed because the target may be moving, and we should adjust to that). m_FollowKnownImperfectPathCountdown = KNOWN_IMPERFECT_PATH_RESET_COUNTDOWN; } } void CCmpUnitMotion::OnTurnStart() { if (PossiblyAtDestination()) MoveSucceeded(); else if (!TargetHasValidPosition()) { // Scrap waypoints - we don't know where to go. // If the move request remains unchanged and the target again has a valid position later on, // moving will be resumed. // Units may want to move to move to the target's last known position, // but that should be decided by UnitAI (handling MoveFailed), not UnitMotion. m_LongPath.m_Waypoints.clear(); m_ShortPath.m_Waypoints.clear(); MoveFailed(); } } void CCmpUnitMotion::PreMove(CCmpUnitMotionManager::MotionState& state) { state.ignore = !m_Pushing || !m_BlockMovement; state.wasObstructed = false; state.wentStraight = false; // If we were idle and will still be, no need for an update. state.needUpdate = state.cmpPosition->IsInWorld() && - (m_CurSpeed != fixed::Zero() || m_MoveRequest.m_Type != MoveRequest::NONE); + (m_CurrentSpeed != fixed::Zero() || m_MoveRequest.m_Type != MoveRequest::NONE); if (!m_BlockMovement) return; state.controlGroup = IsFormationMember() ? m_FormationController : INVALID_ENTITY; // Update moving flag, this is an internal construct used for pushing, // so it does not really reflect whether the unit is actually moving or not. state.isMoving = m_Pushing && m_MoveRequest.m_Type != MoveRequest::NONE; CmpPtr cmpObstruction(GetEntityHandle()); if (cmpObstruction) cmpObstruction->SetMovingFlag(state.isMoving); } void CCmpUnitMotion::Move(CCmpUnitMotionManager::MotionState& state, fixed dt) { PROFILE("Move"); // 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. state.wentStraight = TryGoingStraightToTarget(state.initialPos, true); - state.wasObstructed = PerformMove(dt, state.cmpPosition->GetTurnRate(), m_ShortPath, m_LongPath, state.pos, state.angle); + state.wasObstructed = PerformMove(dt, state.cmpPosition->GetTurnRate(), m_ShortPath, m_LongPath, state.pos, state.speed, state.angle); } void CCmpUnitMotion::PostMove(CCmpUnitMotionManager::MotionState& state, fixed dt) { // Update our speed over this turn so that the visual actor shows the correct animation. if (state.pos == state.initialPos) { if (state.angle != state.initialAngle) state.cmpPosition->TurnTo(state.angle); - UpdateMovementState(fixed::Zero()); + UpdateMovementState(fixed::Zero(), fixed::Zero()); } else { // Update the Position component after our movement (if we actually moved anywhere) CFixedVector2D offset = state.pos - state.initialPos; // When moving always set the angle in the direction of the movement, // if we are not trying to move, assume this is pushing-related movement, // and maintain the current angle instead. if (IsMoveRequested()) state.angle = atan2_approx(offset.X, offset.Y); state.cmpPosition->MoveAndTurnTo(state.pos.X, state.pos.Y, state.angle); // Calculate the mean speed over this past turn. - UpdateMovementState(offset.Length() / dt); + UpdateMovementState(state.speed, offset.Length() / dt); } if (state.wasObstructed && HandleObstructedMove(state.pos != state.initialPos)) return; else if (!state.wasObstructed && state.pos != state.initialPos) m_FailedMovements = 0; // If we moved straight, and didn't quite finish the path, reset - we'll update it next turn if still OK. if (state.wentStraight && !state.wasObstructed) m_ShortPath.m_Waypoints.clear(); // We may need to recompute our path sometimes (e.g. if our target moves). // Since we request paths asynchronously anyways, this does not need to be done before moving. if (!state.wentStraight && PathingUpdateNeeded(state.pos)) { PathGoal goal; if (ComputeGoal(goal, m_MoveRequest)) ComputePathToGoal(state.pos, goal); } else if (m_FollowKnownImperfectPathCountdown > 0) --m_FollowKnownImperfectPathCountdown; } bool CCmpUnitMotion::PossiblyAtDestination() const { if (m_MoveRequest.m_Type == MoveRequest::NONE) return false; CmpPtr cmpObstructionManager(GetSystemEntity()); ENSURE(cmpObstructionManager); if (m_MoveRequest.m_Type == MoveRequest::POINT) return cmpObstructionManager->IsInPointRange(GetEntityId(), m_MoveRequest.m_Position.X, m_MoveRequest.m_Position.Y, m_MoveRequest.m_MinRange, m_MoveRequest.m_MaxRange, false); if (m_MoveRequest.m_Type == MoveRequest::ENTITY) return cmpObstructionManager->IsInTargetRange(GetEntityId(), m_MoveRequest.m_Entity, m_MoveRequest.m_MinRange, m_MoveRequest.m_MaxRange, false); if (m_MoveRequest.m_Type == MoveRequest::OFFSET) { CmpPtr cmpControllerMotion(GetSimContext(), m_MoveRequest.m_Entity); if (cmpControllerMotion && cmpControllerMotion->IsMoveRequested()) return false; // In formation, return a match only if we are exactly at the target position. // Otherwise, units can go in an infinite "walzting" loop when the Idle formation timer // reforms them. CFixedVector2D targetPos; ComputeTargetPosition(targetPos); CmpPtr cmpPosition(GetEntityHandle()); return (targetPos-cmpPosition->GetPosition2D()).CompareLength(fixed::Zero()) <= 0; } return false; } -bool CCmpUnitMotion::PerformMove(fixed dt, const fixed& turnRate, WaypointPath& shortPath, WaypointPath& longPath, CFixedVector2D& pos, entity_angle_t& angle) const +bool CCmpUnitMotion::PerformMove(fixed dt, const fixed& turnRate, WaypointPath& shortPath, WaypointPath& longPath, CFixedVector2D& pos, fixed& speed, entity_angle_t& angle) const { // If there are no waypoint, behave as though we were obstructed and let HandleObstructedMove handle it. if (shortPath.m_Waypoints.empty() && longPath.m_Waypoints.empty()) return true; // Wrap the angle to (-Pi, Pi]. while (angle > entity_angle_t::Pi()) angle -= entity_angle_t::Pi() * 2; while (angle < -entity_angle_t::Pi()) angle += entity_angle_t::Pi() * 2; // TODO: there's some asymmetry here when units look at other // units' positions - the result will depend on the order of execution. // Maybe we should split the updates into multiple phases to minimise // that problem. CmpPtr cmpPathfinder(GetSystemEntity()); ENSURE(cmpPathfinder); fixed basicSpeed = m_Speed; // If in formation, run to keep up; otherwise just walk. if (IsMovingAsFormation()) 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); fixed timeLeft = dt; fixed zero = fixed::Zero(); ICmpObstructionManager::tag_t specificIgnore; if (m_MoveRequest.m_Type == MoveRequest::ENTITY) { CmpPtr cmpTargetObstruction(GetSimContext(), m_MoveRequest.m_Entity); if (cmpTargetObstruction) specificIgnore = cmpTargetObstruction->GetObstruction(); } while (timeLeft > zero) { // If we ran out of path, we have to stop. if (shortPath.m_Waypoints.empty() && longPath.m_Waypoints.empty()) break; CFixedVector2D target; if (shortPath.m_Waypoints.empty()) target = CFixedVector2D(longPath.m_Waypoints.back().x, longPath.m_Waypoints.back().z); else target = CFixedVector2D(shortPath.m_Waypoints.back().x, shortPath.m_Waypoints.back().z); CFixedVector2D offset = target - pos; - if (turnRate > zero && !offset.IsZero()) - { - fixed maxRotation = turnRate.Multiply(timeLeft); - fixed angleDiff = angle - atan2_approx(offset.X, offset.Y); - if (angleDiff != zero) + + fixed angleDiff = angle - atan2_approx(offset.X, offset.Y); + fixed absoluteAngleDiff = angleDiff.Absolute(); + if (absoluteAngleDiff > entity_angle_t::Pi()) + absoluteAngleDiff = entity_angle_t::Pi() * 2 - absoluteAngleDiff; + + // We only rotate to the instantTurnAngle angle. The rest we rotate during movement. + if (absoluteAngleDiff > m_InstantTurnAngle) + { + // Stop moving when rotating this far. + speed = zero; + if (turnRate > zero && !offset.IsZero()) { - fixed absoluteAngleDiff = angleDiff.Absolute(); - if (absoluteAngleDiff > entity_angle_t::Pi()) - absoluteAngleDiff = entity_angle_t::Pi() * 2 - absoluteAngleDiff; + fixed maxRotation = turnRate.Multiply(timeLeft); // Figure out whether rotating will increase or decrease the angle, and how far we need to rotate in that direction. int direction = (entity_angle_t::Zero() < angleDiff && angleDiff <= entity_angle_t::Pi()) || angleDiff < -entity_angle_t::Pi() ? -1 : 1; // Can't rotate far enough, just rotate in the correct direction. - if (absoluteAngleDiff > maxRotation) + if (absoluteAngleDiff - m_InstantTurnAngle > maxRotation) { angle += maxRotation * direction; if (angle * direction > entity_angle_t::Pi()) angle -= entity_angle_t::Pi() * 2 * direction; break; } // Rotate towards the next waypoint and continue moving. angle = atan2_approx(offset.X, offset.Y); - // Give some 'free' rotation for angles below 0.5 radians. - timeLeft = (std::min(maxRotation, maxRotation - absoluteAngleDiff + fixed::FromInt(1)/2)) / turnRate; + timeLeft = std::min(maxRotation, maxRotation - absoluteAngleDiff + m_InstantTurnAngle) / turnRate; } } + else + { + // Modify the speed depending on the angle difference. + fixed sin, cos; + sincos_approx(angleDiff, sin, cos); + speed = speed.Multiply(cos); + } // Work out how far we can travel in timeLeft. - fixed maxdist = maxSpeed.Multiply(timeLeft); + fixed accelTime = std::min(timeLeft, (maxSpeed - speed) / m_Acceleration); + fixed accelDist = speed.Multiply(accelTime) + accelTime.Square().Multiply(m_Acceleration) / 2; + fixed maxdist = accelDist + maxSpeed.Multiply(timeLeft - accelTime); // If the target is close, we can move there directly. fixed offsetLength = offset.Length(); if (offsetLength <= maxdist) { if (cmpPathfinder->CheckMovement(GetObstructionFilter(specificIgnore), 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; + // Either we still need to accelerate after, or we have reached maxSpeed. + // The former is much less likely than the latter: usually we can reach + // maxSpeed within one waypoint. So the Sqrt is not too bad. + if (offsetLength <= accelDist) + { + fixed requiredTime = (-speed + (speed.Square() + offsetLength.Multiply(m_Acceleration).Multiply(fixed::FromInt(2))).Sqrt()) / m_Acceleration; + timeLeft -= requiredTime; + speed += m_Acceleration.Multiply(requiredTime); + } + else + { + timeLeft -= accelTime + (offsetLength - accelDist) / maxSpeed; + speed = maxSpeed; + } if (shortPath.m_Waypoints.empty()) longPath.m_Waypoints.pop_back(); else shortPath.m_Waypoints.pop_back(); continue; } else { // Error - path was obstructed. return true; } } else { // Not close enough, so just move in the right direction. offset.Normalize(maxdist); target = pos + offset; + speed = std::min(maxSpeed, speed + m_Acceleration.Multiply(timeLeft)); + if (cmpPathfinder->CheckMovement(GetObstructionFilter(specificIgnore), pos.X, pos.Y, target.X, target.Y, m_Clearance, m_PassClass)) pos = target; else return true; break; } } return false; } -void CCmpUnitMotion::UpdateMovementState(entity_pos_t speed) +void CCmpUnitMotion::UpdateMovementState(entity_pos_t speed, entity_pos_t meanSpeed) { CmpPtr cmpVisual(GetEntityHandle()); if (cmpVisual) { - if (speed == fixed::Zero()) + if (meanSpeed == fixed::Zero()) cmpVisual->SelectMovementAnimation("idle", fixed::FromInt(1)); else - cmpVisual->SelectMovementAnimation(speed > (m_WalkSpeed / 2).Multiply(m_RunMultiplier + fixed::FromInt(1)) ? "run" : "walk", speed); + cmpVisual->SelectMovementAnimation(meanSpeed > (m_WalkSpeed / 2).Multiply(m_RunMultiplier + fixed::FromInt(1)) ? "run" : "walk", meanSpeed); } - m_CurSpeed = speed; + m_CurrentSpeed = speed; } bool CCmpUnitMotion::HandleObstructedMove(bool moved) { CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return false; // We failed to move, inform other components as they might handle it. // (don't send messages on the first failure, as that would be too noisy). // Also don't increment above the initial MoveObstructed message if we actually manage to move a little. if (!moved || m_FailedMovements < 2) { if (!IncrementFailedMovementsAndMaybeNotify() && m_FailedMovements >= 2) MoveObstructed(); } PathGoal goal; if (!ComputeGoal(goal, m_MoveRequest)) return false; // At this point we have a position in the world since ComputeGoal checked for that. CFixedVector2D pos = cmpPosition->GetPosition2D(); // Assume that we are merely obstructed and the long path is salvageable, so try going around the obstruction. // This could be a separate function, but it doesn't really make sense to call it outside of here, and I can't find a name. // I use an IIFE to have nice 'return' semantics still. if ([&]() -> bool { // If the goal is close enough, we should ignore any remaining long waypoint and just // short path there directly, as that improves behaviour in general - see D2095). if (InShortPathRange(goal, pos)) return false; // On rare occasions, when following a short path, we can end up in a position where // the short pathfinder thinks we are inside an obstruction (and can leave) // but the CheckMovement logic doesn't. I believe the cause is a small numerical difference // in their calculation, but haven't been able to pinpoint it precisely. // In those cases, the solution is to back away to prevent the short-pathfinder from being confused. // TODO: this should only be done if we're obstructed by a static entity. if (!m_ShortPath.m_Waypoints.empty() && m_FailedMovements == BACKUP_HACK_DELAY) { Waypoint next = m_ShortPath.m_Waypoints.back(); CFixedVector2D backUp(pos.X - next.x, pos.Y - next.z); backUp.Normalize(); next.x = pos.X + backUp.X; next.z = pos.Y + backUp.Y; m_ShortPath.m_Waypoints.push_back(next); return true; } // Delete the next waypoint if it's reasonably close, // because it might be blocked by units and thus unreachable. // NB: this number is tricky. Make it too high, and units start going down dead ends, which looks odd (#5795) // Make it too low, and they might get stuck behind other obstructed entities. // It also has performance implications because it calls the short-pathfinder. fixed skipbeyond = std::max(ShortPathSearchRange() / 3, Pathfinding::NAVCELL_SIZE * 8); if (m_LongPath.m_Waypoints.size() > 1 && (pos - CFixedVector2D(m_LongPath.m_Waypoints.back().x, m_LongPath.m_Waypoints.back().z)).CompareLength(skipbeyond) < 0) { m_LongPath.m_Waypoints.pop_back(); } else if (ShouldAlternatePathfinder()) { // Recompute the whole thing occasionally, in case we got stuck in a dead end from removing long waypoints. RequestLongPath(pos, goal); return true; } if (m_LongPath.m_Waypoints.empty()) return false; // Compute a short path in the general vicinity of the next waypoint, to help pathfinding in crowds. // The goal here is to manage to move in the general direction of our target, not to be super accurate. fixed radius = Clamp(skipbeyond/3, Pathfinding::NAVCELL_SIZE * 4, Pathfinding::NAVCELL_SIZE * 12); PathGoal subgoal = { PathGoal::CIRCLE, m_LongPath.m_Waypoints.back().x, m_LongPath.m_Waypoints.back().z, radius }; RequestShortPath(pos, subgoal, false); return true; }()) return true; // If we couldn't use a workaround, try recomputing the entire path. ComputePathToGoal(pos, goal); return true; } bool CCmpUnitMotion::TargetHasValidPosition(const MoveRequest& moveRequest) const { if (moveRequest.m_Type != MoveRequest::ENTITY) return true; CmpPtr cmpPosition(GetSimContext(), moveRequest.m_Entity); return cmpPosition && cmpPosition->IsInWorld(); } bool CCmpUnitMotion::ComputeTargetPosition(CFixedVector2D& out, const MoveRequest& moveRequest) const { if (moveRequest.m_Type == MoveRequest::POINT) { out = moveRequest.m_Position; return true; } CmpPtr cmpTargetPosition(GetSimContext(), moveRequest.m_Entity); if (!cmpTargetPosition || !cmpTargetPosition->IsInWorld()) return false; if (moveRequest.m_Type == MoveRequest::OFFSET) { // There is an offset, so compute it relative to orientation entity_angle_t angle = cmpTargetPosition->GetRotation().Y; CFixedVector2D offset = moveRequest.GetOffset().Rotate(angle); out = cmpTargetPosition->GetPosition2D() + offset; } else { out = cmpTargetPosition->GetPosition2D(); // Position is only updated after all units have moved & pushed. // Therefore, we may need to interpolate the target position, depending on when this call takes place during the turn: // - On "Turn Start", we'll check positions directly without interpolation. // - During movement, we'll call this for direct-pathing & we need to interpolate // (this way, we move where the unit will end up at the end of _this_ turn, making it match on next turn start). // - After movement, we'll call this to request paths & we need to interpolate // (this way, we'll move where the unit ends up in the end of _next_ turn, making it a match in 2 turns). // TODO: This does not really aim many turns in advance, with orthogonal trajectories it probably should. CmpPtr cmpUnitMotion(GetSimContext(), moveRequest.m_Entity); CmpPtr cmpUnitMotionManager(GetSystemEntity()); bool needInterpolation = cmpUnitMotion && cmpUnitMotion->IsMoveRequested() && cmpUnitMotionManager->ComputingMotion(); if (needInterpolation) { // Add predicted movement. CFixedVector2D tempPos = out + (out - cmpTargetPosition->GetPreviousPosition2D()); out = tempPos; } } return true; } bool CCmpUnitMotion::TryGoingStraightToTarget(const CFixedVector2D& from, bool updatePaths) { // Assume if we have short paths we want to follow them. // Exception: offset movement (formations) generally have very short deltas // and to look good we need them to walk-straight most of the time. if (!IsFormationMember() && !m_ShortPath.m_Waypoints.empty()) return false; CFixedVector2D targetPos; if (!ComputeTargetPosition(targetPos)) return false; CmpPtr cmpPathfinder(GetSystemEntity()); if (!cmpPathfinder) return false; // Move the goal to match the target entity's new position PathGoal goal; if (!ComputeGoal(goal, m_MoveRequest)) return false; goal.x = targetPos.X; goal.z = targetPos.Y; // (we ignore changes to the target's rotation, since only buildings are // square and buildings don't move) // Find the point on the goal shape that we should head towards CFixedVector2D goalPos = goal.NearestPointOnGoal(from); // Fail if the target is too far away if ((goalPos - from).CompareLength(DIRECT_PATH_RANGE) > 0) return false; // Check if there's any collisions on that route. // For entity goals, skip only the specific obstruction tag or with e.g. walls we might ignore too many entities. ICmpObstructionManager::tag_t specificIgnore; if (m_MoveRequest.m_Type == MoveRequest::ENTITY) { CmpPtr cmpTargetObstruction(GetSimContext(), m_MoveRequest.m_Entity); if (cmpTargetObstruction) specificIgnore = cmpTargetObstruction->GetObstruction(); } // Check movement against units - we want to use the short pathfinder to walk around those if needed. if (specificIgnore.valid()) { if (!cmpPathfinder->CheckMovement(GetObstructionFilter(specificIgnore), from.X, from.Y, goalPos.X, goalPos.Y, m_Clearance, m_PassClass)) return false; } else if (!cmpPathfinder->CheckMovement(GetObstructionFilter(), from.X, from.Y, goalPos.X, goalPos.Y, m_Clearance, m_PassClass)) return false; if (!updatePaths) return true; // That route is okay, so update our path m_LongPath.m_Waypoints.clear(); m_ShortPath.m_Waypoints.clear(); m_ShortPath.m_Waypoints.emplace_back(Waypoint{ goalPos.X, goalPos.Y }); return true; } bool CCmpUnitMotion::PathingUpdateNeeded(const CFixedVector2D& from) const { if (m_MoveRequest.m_Type == MoveRequest::NONE) return false; CFixedVector2D targetPos; if (!ComputeTargetPosition(targetPos)) return false; if (m_FollowKnownImperfectPathCountdown > 0 && (!m_LongPath.m_Waypoints.empty() || !m_ShortPath.m_Waypoints.empty())) return false; if (PossiblyAtDestination()) return false; // Get the obstruction shape and translate it where we estimate the target to be. ICmpObstructionManager::ObstructionSquare estimatedTargetShape; if (m_MoveRequest.m_Type == MoveRequest::ENTITY) { CmpPtr cmpTargetObstruction(GetSimContext(), m_MoveRequest.m_Entity); if (cmpTargetObstruction) cmpTargetObstruction->GetObstructionSquare(estimatedTargetShape); } estimatedTargetShape.x = targetPos.X; estimatedTargetShape.z = targetPos.Y; CmpPtr cmpObstruction(GetEntityHandle()); ICmpObstructionManager::ObstructionSquare shape; if (cmpObstruction) cmpObstruction->GetObstructionSquare(shape); // Translate our own obstruction shape to our last waypoint or our current position, lacking that. if (m_LongPath.m_Waypoints.empty() && m_ShortPath.m_Waypoints.empty()) { shape.x = from.X; shape.z = from.Y; } else { const Waypoint& lastWaypoint = m_LongPath.m_Waypoints.empty() ? m_ShortPath.m_Waypoints.front() : m_LongPath.m_Waypoints.front(); shape.x = lastWaypoint.x; shape.z = lastWaypoint.z; } CmpPtr cmpObstructionManager(GetSystemEntity()); ENSURE(cmpObstructionManager); // Increase the ranges with distance, to avoid recomputing every turn against units that are moving and far-away for example. entity_pos_t distance = (from - CFixedVector2D(estimatedTargetShape.x, estimatedTargetShape.z)).Length(); // TODO: it could be worth computing this based on time to collision instead of linear distance. entity_pos_t minRange = std::max(m_MoveRequest.m_MinRange - distance / TARGET_UNCERTAINTY_MULTIPLIER, entity_pos_t::Zero()); entity_pos_t maxRange = m_MoveRequest.m_MaxRange < entity_pos_t::Zero() ? m_MoveRequest.m_MaxRange : m_MoveRequest.m_MaxRange + distance / TARGET_UNCERTAINTY_MULTIPLIER; if (cmpObstructionManager->AreShapesInRange(shape, estimatedTargetShape, minRange, maxRange, false)) return false; return true; } void CCmpUnitMotion::FaceTowardsPoint(entity_pos_t x, entity_pos_t z) { CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return; CFixedVector2D pos = cmpPosition->GetPosition2D(); FaceTowardsPointFromPos(pos, x, z); } void CCmpUnitMotion::FaceTowardsPointFromPos(const CFixedVector2D& pos, entity_pos_t x, entity_pos_t z) { CFixedVector2D target(x, z); CFixedVector2D offset = target - pos; if (!offset.IsZero()) { entity_angle_t angle = atan2_approx(offset.X, offset.Y); CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition) return; cmpPosition->TurnTo(angle); } } // The pathfinder cannot go to "rounded rectangles" goals, which are what happens with square targets and a non-null range. // Depending on what the best approximation is, we either pretend the target is a circle or a square. // One needs to be careful that the approximated geometry will be in the range. bool CCmpUnitMotion::ShouldTreatTargetAsCircle(entity_pos_t range, entity_pos_t circleRadius) const { // Given a square, plus a target range we should reach, the shape at that distance // is a round-cornered square which we can approximate as either a circle or as a square. // Previously, we used the shape that minimized the worst-case error. // However that is unsage in some situations. So let's be less clever and // just check if our range is at least three times bigger than the circleradius return (range > circleRadius*3); } bool CCmpUnitMotion::ComputeGoal(PathGoal& out, const MoveRequest& moveRequest) const { if (moveRequest.m_Type == MoveRequest::NONE) return false; CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return false; CFixedVector2D pos = cmpPosition->GetPosition2D(); CFixedVector2D targetPosition; if (!ComputeTargetPosition(targetPosition, moveRequest)) return false; ICmpObstructionManager::ObstructionSquare targetObstruction; if (moveRequest.m_Type == MoveRequest::ENTITY) { CmpPtr cmpTargetObstruction(GetSimContext(), moveRequest.m_Entity); if (cmpTargetObstruction) cmpTargetObstruction->GetObstructionSquare(targetObstruction); } targetObstruction.x = targetPosition.X; targetObstruction.z = targetPosition.Y; ICmpObstructionManager::ObstructionSquare obstruction; CmpPtr cmpObstruction(GetEntityHandle()); if (cmpObstruction) cmpObstruction->GetObstructionSquare(obstruction); else { obstruction.x = pos.X; obstruction.z = pos.Y; } CmpPtr cmpObstructionManager(GetSystemEntity()); ENSURE(cmpObstructionManager); entity_pos_t distance = cmpObstructionManager->DistanceBetweenShapes(obstruction, targetObstruction); out.x = targetObstruction.x; out.z = targetObstruction.z; out.hw = targetObstruction.hw; out.hh = targetObstruction.hh; out.u = targetObstruction.u; out.v = targetObstruction.v; if (moveRequest.m_MinRange > fixed::Zero() || moveRequest.m_MaxRange > fixed::Zero() || targetObstruction.hw > fixed::Zero()) out.type = PathGoal::SQUARE; else { out.type = PathGoal::POINT; return true; } entity_pos_t circleRadius = CFixedVector2D(targetObstruction.hw, targetObstruction.hh).Length(); // TODO: because we cannot move to rounded rectangles, we have to make conservative approximations. // This means we might end up in a situation where cons(max-range) < min range < max range < cons(min-range) // When going outside of the min-range or inside the max-range, the unit will still go through the correct range // but if it moves fast enough, this might not be picked up by PossiblyAtDestination(). // Fixing this involves moving to rounded rectangles, or checking more often in PerformMove(). // In the meantime, one should avoid that 'Speed over a turn' > MaxRange - MinRange, in case where // min-range is not 0 and max-range is not infinity. if (distance < moveRequest.m_MinRange) { // Distance checks are nearest edge to nearest edge, so we need to account for our clearance // and we must make sure diagonals also fit so multiply by slightly more than sqrt(2) entity_pos_t goalDistance = moveRequest.m_MinRange + m_Clearance * 3 / 2; if (ShouldTreatTargetAsCircle(moveRequest.m_MinRange, circleRadius)) { // We are safely away from the obstruction itself if we are away from the circumscribing circle out.type = PathGoal::INVERTED_CIRCLE; out.hw = circleRadius + goalDistance; } else { out.type = PathGoal::INVERTED_SQUARE; out.hw = targetObstruction.hw + goalDistance; out.hh = targetObstruction.hh + goalDistance; } } else if (moveRequest.m_MaxRange >= fixed::Zero() && distance > moveRequest.m_MaxRange) { if (ShouldTreatTargetAsCircle(moveRequest.m_MaxRange, circleRadius)) { entity_pos_t goalDistance = moveRequest.m_MaxRange; // We must go in-range of the inscribed circle, not the circumscribing circle. circleRadius = std::min(targetObstruction.hw, targetObstruction.hh); out.type = PathGoal::CIRCLE; out.hw = circleRadius + goalDistance; } else { // The target is large relative to our range, so treat it as a square and // get close enough that the diagonals come within range entity_pos_t goalDistance = moveRequest.m_MaxRange * 2 / 3; // multiply by slightly less than 1/sqrt(2) out.type = PathGoal::SQUARE; entity_pos_t delta = std::max(goalDistance, m_Clearance + entity_pos_t::FromInt(4)/16); // ensure it's far enough to not intersect the building itself out.hw = targetObstruction.hw + delta; out.hh = targetObstruction.hh + delta; } } // Do nothing in particular in case we are already in range. return true; } void CCmpUnitMotion::ComputePathToGoal(const CFixedVector2D& from, const PathGoal& goal) { #if DISABLE_PATHFINDER { CmpPtr cmpPathfinder (GetSimContext(), SYSTEM_ENTITY); CFixedVector2D goalPos = m_FinalGoal.NearestPointOnGoal(from); m_LongPath.m_Waypoints.clear(); m_ShortPath.m_Waypoints.clear(); m_ShortPath.m_Waypoints.emplace_back(Waypoint{ goalPos.X, goalPos.Y }); return; } #endif // If the target is close enough, hope that we'll be able to go straight next turn. if (!ShouldAlternatePathfinder() && TryGoingStraightToTarget(from, false)) { // NB: since we may fail to move straight next turn, we should edge our bets. // Since the 'go straight' logic currently fires only if there's no short path, // we'll compute a long path regardless to make sure _that_ stays up to date. // (it's also extremely likely to be very fast to compute, so no big deal). m_ShortPath.m_Waypoints.clear(); RequestLongPath(from, goal); 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. // We want to occasionally compute a long path if we're computing short-paths, because the short path domain // is bounded and thus it can't around very large static obstacles. // Likewise, we want to compile a short-path occasionally when the target is far because we might be stuck // on a navcell surrounded by impassable navcells, but the short-pathfinder could move us out of there. bool shortPath = InShortPathRange(goal, from); if (ShouldAlternatePathfinder()) shortPath = !shortPath; if (shortPath) { m_LongPath.m_Waypoints.clear(); // Extend the range so that our first path is probably valid. RequestShortPath(from, goal, true); } else { m_ShortPath.m_Waypoints.clear(); RequestLongPath(from, goal); } } void CCmpUnitMotion::RequestLongPath(const CFixedVector2D& from, const PathGoal& goal) { CmpPtr cmpPathfinder(GetSystemEntity()); if (!cmpPathfinder) return; // this is by how much our waypoints will be apart at most. // this value here seems sensible enough. PathGoal improvedGoal = goal; improvedGoal.maxdist = SHORT_PATH_MIN_SEARCH_RANGE - entity_pos_t::FromInt(1); cmpPathfinder->SetDebugPath(from.X, from.Y, improvedGoal, m_PassClass); m_ExpectedPathTicket.m_Type = Ticket::LONG_PATH; m_ExpectedPathTicket.m_Ticket = cmpPathfinder->ComputePathAsync(from.X, from.Y, improvedGoal, m_PassClass, GetEntityId()); } void CCmpUnitMotion::RequestShortPath(const CFixedVector2D &from, const PathGoal& goal, bool extendRange) { CmpPtr cmpPathfinder(GetSystemEntity()); if (!cmpPathfinder) return; entity_pos_t searchRange = ShortPathSearchRange(); if (extendRange) { CFixedVector2D dist(from.X - goal.x, from.Y - goal.z); if (dist.CompareLength(searchRange - entity_pos_t::FromInt(1)) >= 0) { searchRange = dist.Length() + fixed::FromInt(1); if (searchRange > SHORT_PATH_MAX_SEARCH_RANGE) searchRange = SHORT_PATH_MAX_SEARCH_RANGE; } } m_ExpectedPathTicket.m_Type = Ticket::SHORT_PATH; m_ExpectedPathTicket.m_Ticket = cmpPathfinder->ComputeShortPathAsync(from.X, from.Y, m_Clearance, searchRange, goal, m_PassClass, true, GetGroup(), GetEntityId()); } bool CCmpUnitMotion::MoveTo(MoveRequest request) { PROFILE("MoveTo"); if (request.m_MinRange == request.m_MaxRange && !request.m_MinRange.IsZero()) LOGWARNING("MaxRange must be larger than MinRange; See CCmpUnitMotion.cpp for more information"); CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return false; PathGoal goal; if (!ComputeGoal(goal, request)) return false; m_MoveRequest = request; m_FailedMovements = 0; m_FollowKnownImperfectPathCountdown = 0; ComputePathToGoal(cmpPosition->GetPosition2D(), goal); return true; } bool CCmpUnitMotion::IsTargetRangeReachable(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) { CmpPtr cmpPosition(GetEntityHandle()); if (!cmpPosition || !cmpPosition->IsInWorld()) return false; MoveRequest request(target, minRange, maxRange); PathGoal goal; if (!ComputeGoal(goal, request)) return false; CmpPtr cmpPathfinder(GetSimContext(), SYSTEM_ENTITY); CFixedVector2D pos = cmpPosition->GetPosition2D(); return cmpPathfinder->IsGoalReachable(pos.X, pos.Y, goal, m_PassClass); } void CCmpUnitMotion::RenderPath(const WaypointPath& path, std::vector& lines, CColor color) { bool floating = false; CmpPtr cmpPosition(GetEntityHandle()); if (cmpPosition) floating = cmpPosition->CanFloat(); lines.clear(); std::vector waypointCoords; for (size_t i = 0; i < path.m_Waypoints.size(); ++i) { float x = path.m_Waypoints[i].x.ToFloat(); float z = path.m_Waypoints[i].z.ToFloat(); waypointCoords.push_back(x); waypointCoords.push_back(z); lines.push_back(SOverlayLine()); lines.back().m_Color = color; SimRender::ConstructSquareOnGround(GetSimContext(), x, z, 1.0f, 1.0f, 0.0f, lines.back(), floating); } float x = cmpPosition->GetPosition2D().X.ToFloat(); float z = cmpPosition->GetPosition2D().Y.ToFloat(); waypointCoords.push_back(x); waypointCoords.push_back(z); lines.push_back(SOverlayLine()); lines.back().m_Color = color; SimRender::ConstructLineOnGround(GetSimContext(), waypointCoords, lines.back(), floating); } void CCmpUnitMotion::RenderSubmit(SceneCollector& collector) { if (!m_DebugOverlayEnabled) return; RenderPath(m_LongPath, m_DebugOverlayLongPathLines, OVERLAY_COLOR_LONG_PATH); RenderPath(m_ShortPath, m_DebugOverlayShortPathLines, OVERLAY_COLOR_SHORT_PATH); for (size_t i = 0; i < m_DebugOverlayLongPathLines.size(); ++i) collector.Submit(&m_DebugOverlayLongPathLines[i]); for (size_t i = 0; i < m_DebugOverlayShortPathLines.size(); ++i) collector.Submit(&m_DebugOverlayShortPathLines[i]); } #endif // INCLUDED_CCMPUNITMOTION Index: ps/trunk/source/simulation2/components/ICmpUnitMotion.h =================================================================== --- ps/trunk/source/simulation2/components/ICmpUnitMotion.h (revision 25952) +++ ps/trunk/source/simulation2/components/ICmpUnitMotion.h (revision 25953) @@ -1,162 +1,173 @@ /* Copyright (C) 2021 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * 0 A.D. is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with 0 A.D. If not, see . */ #ifndef INCLUDED_ICMPUNITMOTION #define INCLUDED_ICMPUNITMOTION #include "simulation2/system/Interface.h" #include "simulation2/components/ICmpPathfinder.h" // for pass_class_t #include "simulation2/components/ICmpPosition.h" // for entity_pos_t /** * Motion interface for entities with complex movement capabilities. * (Simpler motion is handled by ICmpMotion instead.) * * It should eventually support different movement speeds, moving to areas * instead of points, moving as part of a group, moving as part of a formation, * etc. */ class ICmpUnitMotion : public IComponent { public: /** * Attempt to walk into range of a to a given point, or as close as possible. * The range is measured from the center of the unit. * If cannot move anywhere at all, or if there is some other error, then returns false. * Otherwise, returns true. * If maxRange is negative, then the maximum range is treated as infinity. */ virtual bool MoveToPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t minRange, entity_pos_t maxRange) = 0; /** * Attempt to walk into range of a given target entity, or as close as possible. * The range is measured between approximately the edges of the unit and the target, so that * maxRange=0 is not unreachably close to the target. * If the unit cannot move anywhere at all, or if there is some other error, then returns false. * Otherwise, returns true. * If maxRange is negative, then the maximum range is treated as infinity. */ virtual bool MoveToTargetRange(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) = 0; /** * Join a formation, and move towards a given offset relative to the formation controller entity. * The unit will remain 'in formation' fromthe perspective of UnitMotion * until SetMemberOfFormation(INVALID_ENTITY) is passed. */ virtual void MoveToFormationOffset(entity_id_t controller, entity_pos_t x, entity_pos_t z) = 0; /** * Set/unset the unit as a formation member. * @param controller - if INVALID_ENTITY, the unit is no longer a formation member. Otherwise it is and this is the controller. */ virtual void SetMemberOfFormation(entity_id_t controller) = 0; /** * Check if the target is reachable. * Don't take this as absolute gospel since there are things that the pathfinder may not detect, such as * entity obstructions in the way, but in general it should return satisfactory results. * The interface is similar to MoveToTargetRange but the move is not attempted. * @return true if the target is assumed reachable, false otherwise. */ virtual bool IsTargetRangeReachable(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) = 0; /** * Turn to look towards the given point. */ virtual void FaceTowardsPoint(entity_pos_t x, entity_pos_t z) = 0; /** * Stop moving immediately. */ virtual void StopMoving() = 0; /** - * Get the distance travelled over the last turn. + * Get the speed at the end of the current turn. */ virtual fixed GetCurrentSpeed() const = 0; /** * @returns true if the unit has a destination. */ virtual bool IsMoveRequested() const = 0; /** * Get the unit template walk speed after modifications. */ virtual fixed GetWalkSpeed() const = 0; /** * Get the unit template running (i.e. max) speed after modifications. */ virtual fixed GetRunMultiplier() const = 0; /** * Returns the ratio of GetSpeed() / GetWalkSpeed(). */ virtual fixed GetSpeedMultiplier() const = 0; /** * Set the current movement speed. * @param speed A multiplier of GetWalkSpeed(). */ virtual void SetSpeedMultiplier(fixed multiplier) = 0; /** * Get the speed at which the unit intends to move. * (regardless of whether the unit is moving or not right now). */ virtual fixed GetSpeed() const = 0; /** * @return the estimated position of the unit in @param dt seconds, * following current paths. This is allowed to 'look into the future'. */ virtual CFixedVector2D EstimateFuturePosition(const fixed dt) const = 0; /** + * Get the current acceleration. + */ + virtual fixed GetAcceleration() const = 0; + + /** + * Set the current acceleration. + * @param acceleration The acceleration. + */ + virtual void SetAcceleration(fixed acceleration) = 0; + + /** * Set whether the unit will turn to face the target point after finishing moving. */ virtual void SetFacePointAfterMove(bool facePointAfterMove) = 0; virtual bool GetFacePointAfterMove() const = 0; /** * Get the unit's passability class. */ virtual pass_class_t GetPassabilityClass() const = 0; /** * Get the passability class name (as defined in pathfinder.xml) */ virtual std::string GetPassabilityClassName() const = 0; /** * Get the unit clearance (used by the Obstruction component) */ virtual entity_pos_t GetUnitClearance() const = 0; /** * Toggle the rendering of debug info. */ virtual void SetDebugOverlay(bool enabled) = 0; DECLARE_INTERFACE_TYPE(UnitMotion) }; #endif // INCLUDED_ICMPUNITMOTION Index: ps/trunk/source/simulation2/components/ICmpUnitMotion.cpp =================================================================== --- ps/trunk/source/simulation2/components/ICmpUnitMotion.cpp (revision 25952) +++ ps/trunk/source/simulation2/components/ICmpUnitMotion.cpp (revision 25953) @@ -1,159 +1,171 @@ /* Copyright (C) 2021 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("MoveToPointRange", ICmpUnitMotion, MoveToPointRange) DEFINE_INTERFACE_METHOD("MoveToTargetRange", ICmpUnitMotion, MoveToTargetRange) DEFINE_INTERFACE_METHOD("MoveToFormationOffset", ICmpUnitMotion, MoveToFormationOffset) DEFINE_INTERFACE_METHOD("SetMemberOfFormation", ICmpUnitMotion, SetMemberOfFormation) DEFINE_INTERFACE_METHOD("IsTargetRangeReachable", ICmpUnitMotion, IsTargetRangeReachable) DEFINE_INTERFACE_METHOD("FaceTowardsPoint", ICmpUnitMotion, FaceTowardsPoint) DEFINE_INTERFACE_METHOD("StopMoving", ICmpUnitMotion, StopMoving) DEFINE_INTERFACE_METHOD("GetCurrentSpeed", ICmpUnitMotion, GetCurrentSpeed) DEFINE_INTERFACE_METHOD("IsMoveRequested", ICmpUnitMotion, IsMoveRequested) DEFINE_INTERFACE_METHOD("GetSpeed", ICmpUnitMotion, GetSpeed) DEFINE_INTERFACE_METHOD("GetWalkSpeed", ICmpUnitMotion, GetWalkSpeed) DEFINE_INTERFACE_METHOD("GetRunMultiplier", ICmpUnitMotion, GetRunMultiplier) DEFINE_INTERFACE_METHOD("EstimateFuturePosition", ICmpUnitMotion, EstimateFuturePosition) DEFINE_INTERFACE_METHOD("SetSpeedMultiplier", ICmpUnitMotion, SetSpeedMultiplier) +DEFINE_INTERFACE_METHOD("GetAcceleration", ICmpUnitMotion, GetAcceleration) +DEFINE_INTERFACE_METHOD("SetAcceleration", ICmpUnitMotion, SetAcceleration) DEFINE_INTERFACE_METHOD("GetPassabilityClassName", ICmpUnitMotion, GetPassabilityClassName) DEFINE_INTERFACE_METHOD("GetUnitClearance", ICmpUnitMotion, GetUnitClearance) DEFINE_INTERFACE_METHOD("SetFacePointAfterMove", ICmpUnitMotion, SetFacePointAfterMove) DEFINE_INTERFACE_METHOD("GetFacePointAfterMove", ICmpUnitMotion, GetFacePointAfterMove) DEFINE_INTERFACE_METHOD("SetDebugOverlay", ICmpUnitMotion, SetDebugOverlay) END_INTERFACE_WRAPPER(UnitMotion) class CCmpUnitMotionScripted : public ICmpUnitMotion { public: DEFAULT_SCRIPT_WRAPPER(UnitMotionScripted) virtual bool MoveToPointRange(entity_pos_t x, entity_pos_t z, entity_pos_t minRange, entity_pos_t maxRange) { return m_Script.Call("MoveToPointRange", x, z, minRange, maxRange); } virtual bool MoveToTargetRange(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) { return m_Script.Call("MoveToTargetRange", target, minRange, maxRange); } virtual void MoveToFormationOffset(entity_id_t target, entity_pos_t x, entity_pos_t z) { m_Script.CallVoid("MoveToFormationOffset", target, x, z); } virtual void SetMemberOfFormation(entity_id_t controller) { m_Script.CallVoid("SetMemberOfFormation", controller); } virtual bool IsTargetRangeReachable(entity_id_t target, entity_pos_t minRange, entity_pos_t maxRange) { return m_Script.Call("IsTargetRangeReachable", target, minRange, maxRange); } virtual void FaceTowardsPoint(entity_pos_t x, entity_pos_t z) { m_Script.CallVoid("FaceTowardsPoint", x, z); } virtual void StopMoving() { m_Script.CallVoid("StopMoving"); } virtual fixed GetCurrentSpeed() const { return m_Script.Call("GetCurrentSpeed"); } virtual bool IsMoveRequested() const { return m_Script.Call("IsMoveRequested"); } virtual fixed GetSpeed() const { return m_Script.Call("GetSpeed"); } virtual fixed GetWalkSpeed() const { return m_Script.Call("GetWalkSpeed"); } virtual fixed GetRunMultiplier() const { return m_Script.Call("GetRunMultiplier"); } virtual void SetSpeedMultiplier(fixed multiplier) { m_Script.CallVoid("SetSpeedMultiplier", multiplier); } virtual fixed GetSpeedMultiplier() const { return m_Script.Call("GetSpeedMultiplier"); } virtual CFixedVector2D EstimateFuturePosition(const fixed dt) const { return m_Script.Call("EstimateFuturePosition", dt); } + virtual fixed GetAcceleration() const + { + return m_Script.Call("GetAcceleration"); + } + + virtual void SetAcceleration(fixed acceleration) + { + m_Script.CallVoid("SetAcceleration", acceleration); + } + virtual void SetFacePointAfterMove(bool facePointAfterMove) { m_Script.CallVoid("SetFacePointAfterMove", facePointAfterMove); } virtual bool GetFacePointAfterMove() const { return m_Script.Call("GetFacePointAfterMove"); } virtual pass_class_t GetPassabilityClass() const { return m_Script.Call("GetPassabilityClass"); } virtual std::string GetPassabilityClassName() const { return m_Script.Call("GetPassabilityClassName"); } virtual entity_pos_t GetUnitClearance() const { return m_Script.Call("GetUnitClearance"); } virtual void SetDebugOverlay(bool enabled) { m_Script.CallVoid("SetDebugOverlay", enabled); } }; REGISTER_COMPONENT_SCRIPT_WRAPPER(UnitMotionScripted)