Index: ps/trunk/binaries/data/mods/public/globalscripts/StatusEffects.js =================================================================== --- ps/trunk/binaries/data/mods/public/globalscripts/StatusEffects.js (revision 24161) +++ ps/trunk/binaries/data/mods/public/globalscripts/StatusEffects.js (revision 24162) @@ -1,44 +1,68 @@ /** * This class provides a cache for accessing status effects metadata stored in JSON files. * Note that status effects need not be defined in JSON files to be handled in-game. * This class must be initialised before using, as initialising it directly in globalscripts would * introduce disk I/O every time e.g. a GUI page is loaded. */ class StatusEffectsMetadata { constructor() { this.statusEffectData = {}; - let files = Engine.ListDirectoryFiles("simulation/data/template_helpers/status_effects", "*.json", false); + let files = Engine.ListDirectoryFiles("simulation/data/status_effects", "*.json", false); for (let filename of files) { let data = Engine.ReadJSONFile(filename); if (!data) continue; if (data.code in this.statusEffectData) { error("Encountered two status effects with the code " + data.code); continue; } - this.statusEffectData[data.code] = data; + this.statusEffectData[data.code] = { + "applierTooltip": data.applierTooltip || "", + "code": data.code, + "icon": data.icon || "default", + "statusName": data.statusName || "data.code", + "receiverTooltip": data.receiverTooltip || "" + }; } } /** - * @returns the default data for @param code status effects, augmented with the given template data, - * or simply @param templateData if the code is not found in JSON files. + * @param {string} code - The code of the Status Effect. + * @return {Object} - The JSON data corresponding to the code. */ - augment(code, templateData) + getData(code) { - if (!templateData && this.statusEffectData[code]) + if (this.statusEffectData[code]) return this.statusEffectData[code]; - if (this.statusEffectData[code]) - return Object.assign({}, this.statusEffectData[code], templateData); + warn("No status effects data found for: " + code + "."); + return {}; + } - return templateData; + getApplierTooltip(code) + { + return this.getData(code).applierTooltip; + } + + getIcon(code) + { + return this.getData(code).icon; + } + + getName(code) + { + return this.getData(code).statusName; + } + + getReceiverTooltip(code) + { + return this.getData(code).receiverTooltip; } } Index: ps/trunk/binaries/data/mods/public/globalscripts/Templates.js =================================================================== --- ps/trunk/binaries/data/mods/public/globalscripts/Templates.js (revision 24161) +++ ps/trunk/binaries/data/mods/public/globalscripts/Templates.js (revision 24162) @@ -1,568 +1,575 @@ /** * 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", "Factions", "CivBonuses", "TeamBonuses", "Structures", "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"); - - // ToDo: Resistance against StatusEffects. + 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] = { "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+/)) { let aura = auraTemplates[auraID]; ret.auras[auraID] = { "name": aura.auraName, "description": aura.auraDescription || null, "radius": aura.radius || null }; } } 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.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.PopulationBonus) ret.cost.populationBonus = getEntityValue("Cost/PopulationBonus"); 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.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.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) { ret.speed = { "walk": getEntityValue("UnitMotion/WalkSpeed"), }; 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.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; } 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/globalscripts/tests/test_statusEffects.js =================================================================== --- ps/trunk/binaries/data/mods/public/globalscripts/tests/test_statusEffects.js (revision 24161) +++ ps/trunk/binaries/data/mods/public/globalscripts/tests/test_statusEffects.js (revision 24162) @@ -1,31 +1,36 @@ let statusEffects = { "test_A": { "code": "test_a", - "StatusName": "A", - "StatusTooltip": "TTA" + "statusName": "A", + "applierTooltip": "TTA" }, "test_B": { "code": "test_b", - "StatusName": "B", - "StatusTooltip": "TTB" + "statusName": "B", + "applierTooltip": "TTB" } }; Engine.ListDirectoryFiles = () => Object.keys(statusEffects); Engine.ReadJSONFile = (file) => statusEffects[file]; let sem = new StatusEffectsMetadata(); -// Template data takes precedence over generic data. -TS_ASSERT_UNEVAL_EQUALS(sem.augment("test_a"), { - "code": "test_a", "StatusName": "A", "StatusTooltip": "TTA" +TS_ASSERT_UNEVAL_EQUALS(sem.getData("test_a"), { + "applierTooltip": "TTA", + "code": "test_a", + "icon": "default", + "statusName": "A", + "receiverTooltip": "" }); -TS_ASSERT_UNEVAL_EQUALS(sem.augment("test_b"), { - "code": "test_b", "StatusName": "B", "StatusTooltip": "TTB" -}); -TS_ASSERT_UNEVAL_EQUALS(sem.augment("test_a", { "StatusName": "test" }), { - "code": "test_a", "StatusName": "test", "StatusTooltip": "TTA" -}); -TS_ASSERT_UNEVAL_EQUALS(sem.augment("test_c", { "StatusName": "test" }), { - "StatusName": "test" +TS_ASSERT_UNEVAL_EQUALS(sem.getData("test_b"), { + "applierTooltip": "TTB", + "code": "test_b", + "icon": "default", + "statusName": "B", + "receiverTooltip": "" }); +TS_ASSERT_UNEVAL_EQUALS(sem.getApplierTooltip("test_a"), "TTA"); +TS_ASSERT_UNEVAL_EQUALS(sem.getIcon("test_b"), "default"); +TS_ASSERT_UNEVAL_EQUALS(sem.getName("test_a"), "A"); +TS_ASSERT_UNEVAL_EQUALS(sem.getReceiverTooltip("test_b"), ""); Index: ps/trunk/binaries/data/mods/public/gui/common/tooltips.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/common/tooltips.js (revision 24161) +++ ps/trunk/binaries/data/mods/public/gui/common/tooltips.js (revision 24162) @@ -1,1015 +1,1055 @@ var g_TooltipTextFormats = { "unit": { "font": "sans-10", "color": "orange" }, "header": { "font": "sans-bold-13" }, "body": { "font": "sans-13" }, "comma": { "font": "sans-12" }, "nameSpecificBig": { "font": "sans-bold-16" }, "nameSpecificSmall": { "font": "sans-bold-12" }, "nameGeneric": { "font": "sans-bold-16" } }; /** * 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", "populationBonus", "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) }); } /** * 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)); - // TODO: Status effects resistance. + if (template.resistance.ApplyStatus) + details.push(getStatusEffectsResistanceTooltip(template.resistance.ApplyStatus)); return 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": 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 => { - let template = g_StatusEffectsMetadata.augment(x, applyStatusTemplate[x]); - return unitFont(translateWithContext("status effect", template.StatusName)); - }).join(commaFont(translate(", "))), + "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 attackLabel = sprintf(headerFont(translate("%(attackType)s")), { "attackType": attackType }); let attackTypeTemplate = template.attack[attackType]; 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) - { - let status_template = g_StatusEffectsMetadata.augment(status, attackTypeTemplate.ApplyStatus[status]); - statusEffectsDetails.push("\n" + g_Indent + g_Indent + getStatusEffectsTooltip(status_template, true)); - } + 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(template, applier) +function getStatusEffectsTooltip(statusCode, template, applier) { let tooltipAttributes = []; - if (applier && template.ApplierTooltip) - tooltipAttributes.push(translate(template.ApplierTooltip)); - else if (!applier && template.ReceiverTooltip) - tooltipAttributes.push(translate(template.ReceiverTooltip)); - + 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(translate(statusData.applierTooltip)); + else if (!applier && statusData.receiverTooltip) + tooltipAttributes.push(translate(statusData.receiverTooltip)); + if (applier) return sprintf(translate("%(statusName)s: %(statusInfo)s %(stackability)s"), { - "statusName": headerFont(translateWithContext("status effect", template.StatusName)), + "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", template.StatusName)), + "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) { if (!template.garrisonHolder) return ""; let tooltips = [ 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(translate("Health")), "second": unitFont(translate("second")), }) ); return tooltips.join(commaFont(translate(", "))); } 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) { return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Number of repairers:")), "details": entState.repairable.numBuilders }) + "\n" + (entState.repairable.numBuilders ? 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.", Math.round(entState.repairable.buildTime.timeRemaining - entState.repairable.buildTime.timeRemainingNew)), { "second": Math.round(entState.repairable.buildTime.timeRemaining - entState.repairable.buildTime.timeRemainingNew) }) : 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) })); } function getBuildTimeTooltip(entState) { return sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Number of builders:")), "details": entState.foundation.numBuilders }) + "\n" + (entState.foundation.numBuilders ? 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.", Math.round(entState.foundation.buildTime.timeRemaining - entState.foundation.buildTime.timeRemainingNew)), { "second": Math.round(entState.foundation.buildTime.timeRemaining - entState.foundation.buildTime.timeRemainingNew) }) : 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) })); } /** * 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) { 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()) // Population bonus is shown in the tooltip if (type != "populationBonus" && 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 ""; // Average the resource rates (TODO: distinguish between subtypes) 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 and skew the results if (subtype !== "ruins") types.push(resource.code + "." + subtype); let [rate, count] = types.reduce((sum, t) => { let r = template.resourceGatherRates[t]; return [sum[0] + (r > 0 ? r : 0), sum[1] + (r > 0 ? 1 : 0)]; }, [0, 0]); if (rate > 0) rates[resource.code] = +(rate / count).toFixed(2); } 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] }) ).join(" ") }); } /** * Returns the resources this entity supplies in the specified entity's tooltip */ function getResourceSupplyTooltip(template) { if (!template.supply) return ""; let supply = template.supply; let type = supply.type[0] == "treasure" ? supply.type[1] : supply.type[0]; // 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(type), // Translation: Marks that a resource supply entity has an unending, infinite, supply of its resource. "amount": Number.isFinite(+supply.amount) ? supply.amount : translate("∞") }); } 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) }) }); } /** * 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) { let popBonus = ""; if (template.cost && template.cost.populationBonus) popBonus = sprintf(translate("%(label)s %(populationBonus)s"), { "label": headerFont(translate("Population Bonus:")), "populationBonus": template.cost.populationBonus }); return popBonus; } /** * 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); if (walk == 0 && run == 0) return ""; 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")) }) }); } 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": translate(auras[auraID].name) })), "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; return sprintf(translate("%(specificName)s (%(genericName)s)"), { "specificName": template.name.specific, "genericName": template.name.generic }); } function getEntityNamesFormatted(template) { if (!template.name.specific) return setStringTags(template.name.generic, g_TooltipTextFormats.nameSpecificBig); // Translation: Example: "Epibátēs Athēnaîos [font="sans-bold-16"](Athenian Marine)[/font]" return sprintf(translate("%(specificName)s %(fontStart)s(%(genericName)s)%(fontEnd)s"), { "specificName": setStringTags(template.name.specific[0], g_TooltipTextFormats.nameSpecificBig) + setStringTags(template.name.specific.slice(1).toUpperCase(), g_TooltipTextFormats.nameSpecificSmall), "genericName": template.name.generic, "fontStart": '[font="' + g_TooltipTextFormats.nameGeneric.font + '"]', "fontEnd": '[/font]' }); } 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."); } Index: ps/trunk/binaries/data/mods/public/gui/session/selection_details.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/session/selection_details.js (revision 24161) +++ ps/trunk/binaries/data/mods/public/gui/session/selection_details.js (revision 24162) @@ -1,543 +1,543 @@ function layoutSelectionSingle() { Engine.GetGUIObjectByName("detailsAreaSingle").hidden = false; Engine.GetGUIObjectByName("detailsAreaMultiple").hidden = true; } function layoutSelectionMultiple() { Engine.GetGUIObjectByName("detailsAreaMultiple").hidden = false; Engine.GetGUIObjectByName("detailsAreaSingle").hidden = true; } function getResourceTypeDisplayName(resourceType) { return resourceNameFirstWord( resourceType.generic == "treasure" ? resourceType.specific : resourceType.generic); } // Updates the health bar of garrisoned units function updateGarrisonHealthBar(entState, selection) { if (!entState.garrisonHolder) return; // Summing up the Health of every single unit let totalGarrisonHealth = 0; let maxGarrisonHealth = 0; for (let selEnt of selection) { let selEntState = GetEntityState(selEnt); if (selEntState.garrisonHolder) for (let ent of selEntState.garrisonHolder.entities) { let state = GetEntityState(ent); totalGarrisonHealth += state.hitpoints || 0; maxGarrisonHealth += state.maxHitpoints || 0; } } // Configuring the health bar let healthGarrison = Engine.GetGUIObjectByName("healthGarrison"); healthGarrison.hidden = totalGarrisonHealth <= 0; if (totalGarrisonHealth > 0) { let healthBarGarrison = Engine.GetGUIObjectByName("healthBarGarrison"); let healthSize = healthBarGarrison.size; healthSize.rtop = 100 - 100 * Math.max(0, Math.min(1, totalGarrisonHealth / maxGarrisonHealth)); healthBarGarrison.size = healthSize; healthGarrison.tooltip = getCurrentHealthTooltip({ "hitpoints": totalGarrisonHealth, "maxHitpoints": maxGarrisonHealth }); } } // Fills out information that most entities have function displaySingle(entState) { // Get general unit and player data let template = GetTemplateData(entState.template); let specificName = template.name.specific; let genericName = template.name.generic; // If packed, add that to the generic name (reduces template clutter) if (genericName && template.pack && template.pack.state == "packed") genericName = sprintf(translate("%(genericName)s — Packed"), { "genericName": genericName }); let playerState = g_Players[entState.player]; let civName = g_CivData[playerState.civ].Name; let civEmblem = g_CivData[playerState.civ].Emblem; let playerName = playerState.name; // Indicate disconnected players by prefixing their name if (g_Players[entState.player].offline) playerName = sprintf(translate("\\[OFFLINE] %(player)s"), { "player": playerName }); // Rank if (entState.identity && entState.identity.rank && entState.identity.classes) { Engine.GetGUIObjectByName("rankIcon").tooltip = sprintf(translate("%(rank)s Rank"), { "rank": translateWithContext("Rank", entState.identity.rank) }); Engine.GetGUIObjectByName("rankIcon").sprite = "stretched:session/icons/ranks/" + entState.identity.rank + ".png"; Engine.GetGUIObjectByName("rankIcon").hidden = false; } else { Engine.GetGUIObjectByName("rankIcon").hidden = true; Engine.GetGUIObjectByName("rankIcon").tooltip = ""; } if (entState.statusEffects) { let statusEffectsSection = Engine.GetGUIObjectByName("statusEffectsIcons"); statusEffectsSection.hidden = false; let statusIcons = statusEffectsSection.children; let i = 0; - for (let effectName in entState.statusEffects) + for (let effectCode in entState.statusEffects) { - let effect = entState.statusEffects[effectName]; + let effect = entState.statusEffects[effectCode]; statusIcons[i].hidden = false; - statusIcons[i].sprite = "stretched:session/icons/status_effects/" + (effect.Icon || "default") + ".png"; - statusIcons[i].tooltip = getStatusEffectsTooltip(effect, false); + statusIcons[i].sprite = "stretched:session/icons/status_effects/" + g_StatusEffectsMetadata.getIcon(effect.baseCode) + ".png"; + statusIcons[i].tooltip = getStatusEffectsTooltip(effect.baseCode, effect, false); let size = statusIcons[i].size; size.top = i * 18; size.bottom = i * 18 + 16; statusIcons[i].size = size; if (++i >= statusIcons.length) break; } for (; i < statusIcons.length; ++i) statusIcons[i].hidden = true; } else Engine.GetGUIObjectByName("statusEffectsIcons").hidden = true; let showHealth = entState.hitpoints; let showResource = entState.resourceSupply; let healthSection = Engine.GetGUIObjectByName("healthSection"); let captureSection = Engine.GetGUIObjectByName("captureSection"); let resourceSection = Engine.GetGUIObjectByName("resourceSection"); let sectionPosTop = Engine.GetGUIObjectByName("sectionPosTop"); let sectionPosMiddle = Engine.GetGUIObjectByName("sectionPosMiddle"); let sectionPosBottom = Engine.GetGUIObjectByName("sectionPosBottom"); // Hitpoints healthSection.hidden = !showHealth; if (showHealth) { let unitHealthBar = Engine.GetGUIObjectByName("healthBar"); let healthSize = unitHealthBar.size; healthSize.rright = 100 * Math.max(0, Math.min(1, entState.hitpoints / entState.maxHitpoints)); unitHealthBar.size = healthSize; Engine.GetGUIObjectByName("healthStats").caption = sprintf(translate("%(hitpoints)s / %(maxHitpoints)s"), { "hitpoints": Math.ceil(entState.hitpoints), "maxHitpoints": Math.ceil(entState.maxHitpoints) }); healthSection.size = sectionPosTop.size; captureSection.size = showResource ? sectionPosMiddle.size : sectionPosBottom.size; resourceSection.size = showResource ? sectionPosBottom.size : sectionPosMiddle.size; } else { captureSection.size = sectionPosBottom.size; resourceSection.size = sectionPosTop.size; } // CapturePoints captureSection.hidden = !entState.capturePoints; if (entState.capturePoints) { let setCaptureBarPart = function(playerID, startSize) { let unitCaptureBar = Engine.GetGUIObjectByName("captureBar[" + playerID + "]"); let sizeObj = unitCaptureBar.size; sizeObj.rleft = startSize; let size = 100 * Math.max(0, Math.min(1, entState.capturePoints[playerID] / entState.maxCapturePoints)); sizeObj.rright = startSize + size; unitCaptureBar.size = sizeObj; unitCaptureBar.sprite = "color:" + g_DiplomacyColors.getPlayerColor(playerID, 128); unitCaptureBar.hidden = false; return startSize + size; }; // first handle the owner's points, to keep those points on the left for clarity let size = setCaptureBarPart(entState.player, 0); for (let i in entState.capturePoints) if (i != entState.player) size = setCaptureBarPart(i, size); let captureText = sprintf(translate("%(capturePoints)s / %(maxCapturePoints)s"), { "capturePoints": Math.ceil(entState.capturePoints[entState.player]), "maxCapturePoints": Math.ceil(entState.maxCapturePoints) }); let showSmallCapture = showResource && showHealth; Engine.GetGUIObjectByName("captureStats").caption = showSmallCapture ? "" : captureText; Engine.GetGUIObjectByName("capture").tooltip = showSmallCapture ? captureText : ""; } // Experience Engine.GetGUIObjectByName("experience").hidden = !entState.promotion; if (entState.promotion) { let experienceBar = Engine.GetGUIObjectByName("experienceBar"); let experienceSize = experienceBar.size; experienceSize.rtop = 100 - (100 * Math.max(0, Math.min(1, 1.0 * +entState.promotion.curr / +entState.promotion.req))); experienceBar.size = experienceSize; if (entState.promotion.curr < entState.promotion.req) Engine.GetGUIObjectByName("experience").tooltip = sprintf(translate("%(experience)s %(current)s / %(required)s"), { "experience": "[font=\"sans-bold-13\"]" + translate("Experience:") + "[/font]", "current": Math.floor(entState.promotion.curr), "required": entState.promotion.req }); else Engine.GetGUIObjectByName("experience").tooltip = sprintf(translate("%(experience)s %(current)s"), { "experience": "[font=\"sans-bold-13\"]" + translate("Experience:") + "[/font]", "current": Math.floor(entState.promotion.curr) }); } // Resource stats resourceSection.hidden = !showResource; if (entState.resourceSupply) { let resources = entState.resourceSupply.isInfinite ? translate("∞") : // Infinity symbol sprintf(translate("%(amount)s / %(max)s"), { "amount": Math.ceil(+entState.resourceSupply.amount), "max": entState.resourceSupply.max }); let unitResourceBar = Engine.GetGUIObjectByName("resourceBar"); let resourceSize = unitResourceBar.size; resourceSize.rright = entState.resourceSupply.isInfinite ? 100 : 100 * Math.max(0, Math.min(1, +entState.resourceSupply.amount / +entState.resourceSupply.max)); unitResourceBar.size = resourceSize; Engine.GetGUIObjectByName("resourceLabel").caption = sprintf(translate("%(resource)s:"), { "resource": getResourceTypeDisplayName(entState.resourceSupply.type) }); Engine.GetGUIObjectByName("resourceStats").caption = resources; } let resourceCarryingIcon = Engine.GetGUIObjectByName("resourceCarryingIcon"); let resourceCarryingText = Engine.GetGUIObjectByName("resourceCarryingText"); resourceCarryingIcon.hidden = false; resourceCarryingText.hidden = false; // Resource carrying if (entState.resourceCarrying && entState.resourceCarrying.length) { // We should only be carrying one resource type at once, so just display the first let carried = entState.resourceCarrying[0]; resourceCarryingIcon.sprite = "stretched:session/icons/resources/" + carried.type + ".png"; resourceCarryingText.caption = sprintf(translate("%(amount)s / %(max)s"), { "amount": carried.amount, "max": carried.max }); resourceCarryingIcon.tooltip = ""; } // Use the same indicators for traders else if (entState.trader && entState.trader.goods.amount) { resourceCarryingIcon.sprite = "stretched:session/icons/resources/" + entState.trader.goods.type + ".png"; let totalGain = entState.trader.goods.amount.traderGain; if (entState.trader.goods.amount.market1Gain) totalGain += entState.trader.goods.amount.market1Gain; if (entState.trader.goods.amount.market2Gain) totalGain += entState.trader.goods.amount.market2Gain; resourceCarryingText.caption = totalGain; resourceCarryingIcon.tooltip = sprintf(translate("Gain: %(gain)s"), { "gain": getTradingTooltip(entState.trader.goods.amount) }); } // And for number of workers else if (entState.foundation) { resourceCarryingIcon.sprite = "stretched:session/icons/repair.png"; resourceCarryingIcon.tooltip = getBuildTimeTooltip(entState); resourceCarryingText.caption = entState.foundation.numBuilders ? Engine.FormatMillisecondsIntoDateStringGMT(entState.foundation.buildTime.timeRemaining * 1000, translateWithContext("countdown format", "m:ss")) : ""; } else if (entState.resourceSupply && (!entState.resourceSupply.killBeforeGather || !entState.hitpoints)) { resourceCarryingIcon.sprite = "stretched:session/icons/repair.png"; resourceCarryingText.caption = sprintf(translate("%(amount)s / %(max)s"), { "amount": entState.resourceSupply.numGatherers, "max": entState.resourceSupply.maxGatherers }); Engine.GetGUIObjectByName("resourceCarryingIcon").tooltip = translate("Current/max gatherers"); } else if (entState.repairable && entState.needsRepair) { resourceCarryingIcon.sprite = "stretched:session/icons/repair.png"; resourceCarryingIcon.tooltip = getRepairTimeTooltip(entState); resourceCarryingText.caption = entState.repairable.numBuilders ? Engine.FormatMillisecondsIntoDateStringGMT(entState.repairable.buildTime.timeRemaining * 1000, translateWithContext("countdown format", "m:ss")) : ""; } else { resourceCarryingIcon.hidden = true; resourceCarryingText.hidden = true; } Engine.GetGUIObjectByName("specific").caption = specificName; Engine.GetGUIObjectByName("player").caption = playerName; Engine.GetGUIObjectByName("playerColorBackground").sprite = "color:" + g_DiplomacyColors.getPlayerColor(entState.player, 128); Engine.GetGUIObjectByName("generic").caption = genericName == specificName ? "" : sprintf(translate("(%(genericName)s)"), { "genericName": genericName }); let isGaia = playerState.civ == "gaia"; Engine.GetGUIObjectByName("playerCivIcon").sprite = isGaia ? "" : "stretched:grayscale:" + civEmblem; Engine.GetGUIObjectByName("player").tooltip = isGaia ? "" : civName; // TODO: we should require all entities to have icons Engine.GetGUIObjectByName("icon").sprite = template.icon ? ("stretched:session/portraits/" + template.icon) : "BackgroundBlack"; if (template.icon) Engine.GetGUIObjectByName("iconBorder").onPressRight = () => { showTemplateDetails(entState.template); }; Engine.GetGUIObjectByName("attackAndResistanceStats").tooltip = [ getAttackTooltip, getHealerTooltip, getResistanceTooltip, getGatherTooltip, getSpeedTooltip, getGarrisonTooltip, getProjectilesTooltip, getResourceTrickleTooltip, getLootTooltip ].map(func => func(entState)).filter(tip => tip).join("\n"); let iconTooltips = []; if (genericName) iconTooltips.push("[font=\"sans-bold-16\"]" + genericName + "[/font]"); iconTooltips = iconTooltips.concat([ getVisibleEntityClassesFormatted, getAurasTooltip, getEntityTooltip, showTemplateViewerOnRightClickTooltip ].map(func => func(template))); Engine.GetGUIObjectByName("iconBorder").tooltip = iconTooltips.filter(tip => tip).join("\n"); Engine.GetGUIObjectByName("detailsAreaSingle").hidden = false; Engine.GetGUIObjectByName("detailsAreaMultiple").hidden = true; } // Fills out information for multiple entities function displayMultiple(entStates) { let averageHealth = 0; let maxHealth = 0; let maxCapturePoints = 0; let capturePoints = (new Array(g_MaxPlayers + 1)).fill(0); let playerID = 0; let totalCarrying = {}; let totalLoot = {}; for (let entState of entStates) { playerID = entState.player; // trust that all selected entities have the same owner if (entState.hitpoints) { averageHealth += entState.hitpoints; maxHealth += entState.maxHitpoints; } if (entState.capturePoints) { maxCapturePoints += entState.maxCapturePoints; capturePoints = entState.capturePoints.map((v, i) => v + capturePoints[i]); } let carrying = calculateCarriedResources( entState.resourceCarrying || null, entState.trader && entState.trader.goods ); if (entState.loot) for (let type in entState.loot) totalLoot[type] = (totalLoot[type] || 0) + entState.loot[type]; for (let type in carrying) { totalCarrying[type] = (totalCarrying[type] || 0) + carrying[type]; totalLoot[type] = (totalLoot[type] || 0) + carrying[type]; } } Engine.GetGUIObjectByName("healthMultiple").hidden = averageHealth <= 0; if (averageHealth > 0) { let unitHealthBar = Engine.GetGUIObjectByName("healthBarMultiple"); let healthSize = unitHealthBar.size; healthSize.rtop = 100 - 100 * Math.max(0, Math.min(1, averageHealth / maxHealth)); unitHealthBar.size = healthSize; Engine.GetGUIObjectByName("healthMultiple").tooltip = getCurrentHealthTooltip({ "hitpoints": averageHealth, "maxHitpoints": maxHealth }); } Engine.GetGUIObjectByName("captureMultiple").hidden = maxCapturePoints <= 0; if (maxCapturePoints > 0) { let setCaptureBarPart = function(pID, startSize) { let unitCaptureBar = Engine.GetGUIObjectByName("captureBarMultiple[" + pID + "]"); let sizeObj = unitCaptureBar.size; sizeObj.rtop = startSize; let size = 100 * Math.max(0, Math.min(1, capturePoints[pID] / maxCapturePoints)); sizeObj.rbottom = startSize + size; unitCaptureBar.size = sizeObj; unitCaptureBar.sprite = "color:" + g_DiplomacyColors.getPlayerColor(pID, 128); unitCaptureBar.hidden = false; return startSize + size; }; let size = 0; for (let i in capturePoints) if (i != playerID) size = setCaptureBarPart(i, size); // last handle the owner's points, to keep those points on the bottom for clarity setCaptureBarPart(playerID, size); Engine.GetGUIObjectByName("captureMultiple").tooltip = getCurrentHealthTooltip( { "hitpoints": capturePoints[playerID], "maxHitpoints": maxCapturePoints }, translate("Capture Points:")); } let numberOfUnits = Engine.GetGUIObjectByName("numberOfUnits"); numberOfUnits.caption = entStates.length; numberOfUnits.tooltip = ""; if (Object.keys(totalCarrying).length) numberOfUnits.tooltip = sprintf(translate("%(label)s %(details)s\n"), { "label": headerFont(translate("Carrying:")), "details": bodyFont(Object.keys(totalCarrying).filter( res => totalCarrying[res] != 0).map( res => sprintf(translate("%(type)s %(amount)s"), { "type": resourceIcon(res), "amount": totalCarrying[res] })).join(" ")) }); if (Object.keys(totalLoot).length) numberOfUnits.tooltip += sprintf(translate("%(label)s %(details)s"), { "label": headerFont(translate("Loot:")), "details": bodyFont(Object.keys(totalLoot).filter( res => totalLoot[res] != 0).map( res => sprintf(translate("%(type)s %(amount)s"), { "type": resourceIcon(res), "amount": totalLoot[res] })).join(" ")) }); // Unhide Details Area Engine.GetGUIObjectByName("detailsAreaMultiple").hidden = false; Engine.GetGUIObjectByName("detailsAreaSingle").hidden = true; } // Updates middle entity Selection Details Panel and left Unit Commands Panel function updateSelectionDetails() { let supplementalDetailsPanel = Engine.GetGUIObjectByName("supplementalSelectionDetails"); let detailsPanel = Engine.GetGUIObjectByName("selectionDetails"); let commandsPanel = Engine.GetGUIObjectByName("unitCommands"); let entStates = []; for (let sel of g_Selection.toList()) { let entState = GetEntityState(sel); if (!entState) continue; entStates.push(entState); } if (entStates.length == 0) { Engine.GetGUIObjectByName("detailsAreaMultiple").hidden = true; Engine.GetGUIObjectByName("detailsAreaSingle").hidden = true; hideUnitCommands(); supplementalDetailsPanel.hidden = true; detailsPanel.hidden = true; commandsPanel.hidden = true; return; } // Fill out general info and display it if (entStates.length == 1) displaySingle(entStates[0]); else displayMultiple(entStates); // Show basic details. detailsPanel.hidden = false; // Fill out commands panel for specific unit selected (or first unit of primary group) updateUnitCommands(entStates, supplementalDetailsPanel, commandsPanel); // Show health bar for garrisoned units if the garrison panel is visible if (Engine.GetGUIObjectByName("unitGarrisonPanel") && !Engine.GetGUIObjectByName("unitGarrisonPanel").hidden) updateGarrisonHealthBar(entStates[0], g_Selection.toList()); } function tradingGainString(gain, owner) { // Translation: Used in the trading gain tooltip return sprintf(translate("%(gain)s (%(player)s)"), { "gain": gain, "player": GetSimState().players[owner].name }); } /** * Returns a message with the details of the trade gain. */ function getTradingTooltip(gain) { if (!gain) return ""; let markets = [ { "gain": gain.market1Gain, "owner": gain.market1Owner }, { "gain": gain.market2Gain, "owner": gain.market2Owner } ]; let primaryGain = gain.traderGain; for (let market of markets) if (market.gain && market.owner == gain.traderOwner) // Translation: Used in the trading gain tooltip to concatenate profits of different players primaryGain += translate("+") + market.gain; let tooltip = tradingGainString(primaryGain, gain.traderOwner); for (let market of markets) if (market.gain && market.owner != gain.traderOwner) tooltip += translateWithContext("Separation mark in an enumeration", ", ") + tradingGainString(market.gain, market.owner); return tooltip; } Index: ps/trunk/binaries/data/mods/public/l10n/messages.json =================================================================== --- ps/trunk/binaries/data/mods/public/l10n/messages.json (revision 24161) +++ ps/trunk/binaries/data/mods/public/l10n/messages.json (revision 24162) @@ -1,808 +1,799 @@ [ { "output": "public-civilizations.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "json", "filemasks": [ "simulation/data/civs/**.json" ], "options": { "keywords": [ "Name", "Description", "History", "Special", "AINames" ] } } ] }, { "output": "public-gui-ingame.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "javascript", "filemasks": [ "gui/session/**.js" ], "options": { "format": "javascript-format", "keywords": { "translate": [1], "translatePlural": [1, 2], "translateWithContext": [[1], 2], "translatePluralWithContext": [[1], 2, 3], "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "Translation:" ] } }, { "extractor": "xml", "filemasks": [ "gui/session/**.xml" ], "options": { "keywords": { "translatableAttribute": { "locationAttributes": ["id"] }, "translate": {} } } } ] }, { "output": "public-gui-gamesetup.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "javascript", "filemasks": [ "gui/gamesetup/**.js", "gui/gamesetup_mp/**.js", "gui/loading/**.js" ], "options": { "format": "javascript-format", "keywords": { "translate": [1], "translatePlural": [1, 2], "translateWithContext": [[1], 2], "translatePluralWithContext": [[1], 2, 3], "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "Translation:" ] } }, { "extractor": "xml", "filemasks": [ "gui/gamesetup/**.xml", "gui/gamesetup_mp/**.xml", "gui/loading/**.xml" ], "options": { "keywords": { "translatableAttribute": { "locationAttributes": ["id"] }, "translate": {} } } }, { "extractor": "txt", "filemasks": [ "gui/text/quotes.txt" ], "options": { } } ] }, { "output": "public-gui-lobby.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "javascript", "filemasks": [ "gui/lobby/**.js", "gui/prelobby/**.js" ], "options": { "format": "javascript-format", "keywords": { "translate": [1], "translatePlural": [1, 2], "translateWithContext": [[1], 2], "translatePluralWithContext": [[1], 2, 3], "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "Translation:" ] } }, { "extractor": "xml", "filemasks": [ "gui/lobby/**.xml", "gui/prelobby/**.xml" ], "options": { "keywords": { "translatableAttribute": { "locationAttributes": ["id"] }, "translate": {} } } }, { "extractor": "txt", "filemasks": [ "gui/prelobby/common/terms/*.txt" ], "options": { } } ] }, { "output": "public-gui-manual.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "javascript", "filemasks": [ "gui/manual/**.js" ], "options": { "format": "javascript-format", "keywords": { "translate": [1], "translatePlural": [1, 2], "translateWithContext": [[1], 2], "translatePluralWithContext": [[1], 2, 3], "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "Translation:" ] } }, { "extractor": "xml", "filemasks": [ "gui/manual/**.xml" ], "options": { "keywords": { "translatableAttribute": { "locationAttributes": ["id"] }, "translate": {} } } }, { "extractor": "txt", "filemasks": [ "gui/manual/intro.txt" ], "options": { } } ] }, { "output": "public-gui-userreport.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "txt", "filemasks": [ "gui/userreport/**.txt" ], "options": { } } ] }, { "output": "public-gui-other.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "javascript", "filemasks": [ "globalscripts/**.js", "gui/civinfo/**.js", "gui/common/**.js", "gui/credits/**.js", "gui/loadgame/**.js", "gui/locale/**.js", "gui/options/**.js", "gui/pregame/**.js", "gui/reference/common/**.js", "gui/reference/structree/**.js", "gui/reference/viewer/**.js", "gui/replaymenu/**.js", "gui/splashscreen/**.js", "gui/summary/**.js" ], "options": { "format": "javascript-format", "keywords": { "translate": [1], "translatePlural": [1, 2], "translateWithContext": [[1], 2], "translatePluralWithContext": [[1], 2, 3], "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "dennis-ignore:", "Translation:" ] } }, { "extractor": "xml", "filemasks": [ "globalscripts/**.xml", "gui/civinfo/**.xml", "gui/common/**.xml", "gui/credits/**.xml", "gui/loadgame/**.xml", "gui/locale/**.xml", "gui/options/**.xml", "gui/pregame/**.xml", "gui/reference/structree/**.xml", "gui/reference/viewer/**.xml", "gui/replaymenu/**.xml", "gui/splashscreen/**.xml", "gui/summary/**.xml" ], "options": { "keywords": { "translatableAttribute": { "locationAttributes": ["id"] }, "translate": {} } } }, { "extractor": "json", "filemasks": [ "gui/credits/texts/**.json" ], "options": { "keywords": [ "Title", "Subtitle" ] } }, { "extractor": "json", "filemasks": [ "gui/options/**.json" ], "options": { "keywords": [ "label", "tooltip" ] } }, { "extractor": "json", "filemasks": [ "simulation/data/resources/**.json" ], "options": { "keywords": [ "description" ] } }, { "extractor": "json", "filemasks": [ "simulation/data/resources/**.json" ], "options": { "keywords": [ "name", "subtypes" ], "comments": [ "Translation: Word as used at the beginning of a sentence or as a single-word sentence." ], "context": "firstWord" } }, { "extractor": "json", "filemasks": [ "simulation/data/resources/**.json" ], "options": { "keywords": [ "name", "subtypes" ], "comments": [ "Translation: Word as used in the middle of a sentence (which may require using lowercase for your language)." ], "context": "withinSentence" } }, { "extractor": "txt", "filemasks": [ "gui/gamesetup/**.txt", "gui/splashscreen/splashscreen.txt", "gui/text/tips/**.txt" ], "options": { } } ] }, { "output": "public-templates-units.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "xml", "filemasks": [ "simulation/templates/template_unit_*.xml", "simulation/templates/units/**.xml" ], "options": { "keywords": { "StatusName": { "customContext": "status effect" }, "ApplierTooltip": { "customContext": "status effect" }, "ReceiverTooltip": { "customContext": "status effect" }, "GenericName": {}, "SpecificName": {}, "History": {}, "VisibleClasses": { "splitOnWhitespace": true }, "Tooltip": {}, "DisabledTooltip": {}, "FormationName": {}, "FromClass": {}, "Rank": { "tagAsContext": true } } } } ] }, { "output": "public-templates-buildings.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "xml", "filemasks": [ "simulation/templates/template_structure_*.xml", "simulation/templates/structures/**.xml" ], "options": { "keywords": { "StatusName": { "customContext": "status effect" }, "ApplierTooltip": { "customContext": "status effect" }, "ReceiverTooltip": { "customContext": "status effect" }, "GenericName": {}, "SpecificName": {}, "History": {}, "VisibleClasses": { "splitOnWhitespace": true }, "Tooltip": {}, "DisabledTooltip": {}, "FormationName": {}, "FromClass": {}, "Rank": { "tagAsContext": true } } } } ] }, { "output": "public-templates-other.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "xml", "filemasks": { "includeMasks": [ "simulation/templates/**.xml" ], "excludeMasks": [ "simulation/templates/structures/**.xml", "simulation/templates/template_structure_*.xml", "simulation/templates/template_unit_*.xml", "simulation/templates/units/**.xml" ] }, "options": { "keywords": { - "StatusName": { - "customContext": "status effect" - }, - "ApplierTooltip": { - "customContext": "status effect" - }, - "ReceiverTooltip": { - "customContext": "status effect" - }, "GenericName": {}, "SpecificName": {}, "History": {}, "VisibleClasses": { "splitOnWhitespace": true }, "Tooltip": {}, "DisabledTooltip": {}, "FormationName": {}, "FromClass": {}, "Rank": { "tagAsContext": true } } } }, { "extractor": "json", "filemasks": [ "simulation/data/template_helpers/damage_types/*.json" ], "options": { "keywords": [ "name", "description" ], "context": "damage type" } }, { "extractor": "json", "filemasks": [ - "simulation/data/template_helpers/status_effects/*.json" + "simulation/data/status_effects/*.json" ], "options": { "keywords": [ - "StatusName", - "ApplierTooltip", - "ReceiverTooltip" + "statusName", + "applierTooltip", + "receiverTooltip" ], "context": "status effect" } } ] }, { "output": "public-simulation-auras.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "json", "filemasks": [ "simulation/data/auras/**.json" ], "options": { "keywords": [ "auraName", "auraDescription" ] } } ] }, { "output": "public-simulation-technologies.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "json", "filemasks": [ "simulation/data/technologies/**.json" ], "options": { "keywords": [ "specificName", "genericName", "description", "tooltip", "requirementsTooltip" ] } } ] }, { "output": "public-simulation-other.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "javascript", "filemasks": [ "simulation/ai/**.js", "simulation/components/**.js", "simulation/helpers/**.js" ], "options": { "format": "javascript-format", "keywords": { "translate": [1], "translatePlural": [1, 2], "translateWithContext": [[1], 2], "translatePluralWithContext": [[1], 2, 3], "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "Translation:" ] } }, { "extractor": "json", "filemasks": [ "simulation/data/settings/player_defaults.json" ], "options": { "keywords": [ "Name" ] } }, { "extractor": "json", "filemasks": [ "simulation/data/settings/game_speeds.json" ], "options": { "keywords": ["Title"] } }, { "extractor": "json", "filemasks": [ "simulation/data/settings/victory_conditions/*.json" ], "options": { "keywords": ["Title", "Description"] } }, { "extractor": "json", "filemasks": [ "simulation/data/settings/starting_resources.json" ], "options": { "keywords": ["Title"], "context": "startingResources" } }, { "extractor": "json", "filemasks": [ "simulation/data/settings/trigger_difficulties.json" ], "options": { "keywords": ["Title", "Tooltip"] } }, { "extractor": "json", "filemasks": [ "simulation/data/settings/map_sizes.json" ], "options": { "keywords": [ "Name", "Tooltip" ] } }, { "extractor": "json", "filemasks": [ "simulation/ai/**.json" ], "options": { "keywords": [ "name", "description" ] } } ] }, { "output": "public-maps.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "json", "filemasks": { "includeMasks": [ "maps/random/**.json" ], "excludeMasks": [ "maps/random/rmbiome/**.json" ] }, "options": { "keywords": [ "Name", "Description" ] } }, { "extractor": "javascript", "filemasks": [ "maps/scenarios/**.js", "maps/skirmishes/**.js", "maps/random/**.js" ], "options": { "format": "javascript-format", "keywords": { "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "Translation:" ] } }, { "extractor": "xml", "filemasks": [ "maps/scenarios/**.xml", "maps/skirmishes/**.xml" ], "options": { "keywords": { "ScriptSettings": { "extractJson": { "keywords": [ "Name", "Description" ] } } } } }, { "extractor": "json", "filemasks": [ "maps/random/rmbiome/**.json" ], "options": { "keywords": ["Description"], "context": "biome definition" } } ] }, { "output": "public-tutorials.pot", "inputRoot": "..", "project": "0 A.D. — Empires Ascendant", "copyrightHolder": "Wildfire Games", "rules": [ { "extractor": "javascript", "filemasks": [ "maps/tutorials/**.js" ], "options": { "format": "javascript-format", "keywords": { "markForTranslation": [1], "markForTranslationWithContext": [[1], 2], "markForPluralTranslation": [1, 2] }, "commentTags": [ "Translation:" ] } }, { "extractor": "xml", "filemasks": [ "maps/tutorials/**.xml" ], "options": { "keywords": { "ScriptSettings": { "extractJson": { "keywords": [ "Name", "Description" ] } } } } } ] } ] Index: ps/trunk/binaries/data/mods/public/simulation/components/Resistance.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/Resistance.js (revision 24161) +++ ps/trunk/binaries/data/mods/public/simulation/components/Resistance.js (revision 24162) @@ -1,128 +1,154 @@ function Resistance() {} /** * Builds a RelaxRNG schema of possible attack effects. - * ToDo: Resistance to StatusEffects. * * @return {string} - RelaxNG schema string. */ Resistance.prototype.BuildResistanceSchema = function() { return "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + "" + ""; }; Resistance.prototype.Schema = "Controls the damage resistance of the unit." + "" + "" + "" + "10.0" + "0.0" + "5.0" + "" + "10" + "" + "" + "" + "5" + "" + "" + "" + "" + "" + "" + Resistance.prototype.BuildResistanceSchema() + "" + "" + Resistance.prototype.BuildResistanceSchema() + "" + "" + ""; Resistance.prototype.Init = function() { this.invulnerable = false; }; Resistance.prototype.IsInvulnerable = function() { return this.invulnerable; }; Resistance.prototype.SetInvulnerability = function(invulnerability) { this.invulnerable = invulnerability; Engine.PostMessage(this.entity, MT_InvulnerabilityChanged, { "entity": this.entity, "invulnerability": invulnerability }); }; /** * Calculate the effective resistance of an entity to a particular effect. * ToDo: Support resistance against status effects. * @param {string} effectType - The type of attack effect the resistance has to be calculated for (e.g. "Damage", "Capture"). * @return {Object} - An object of the type { "Damage": { "Crush": number, "Hack": number }, "Capture": number }. */ Resistance.prototype.GetEffectiveResistanceAgainst = function(effectType) { let ret = {}; let template = this.GetResistanceOfForm(Engine.QueryInterface(this.entity, IID_Foundation) ? "Foundation" : "Entity"); if (template[effectType]) ret[effectType] = template[effectType]; return ret; }; /** * Get all separate resistances for showing in the GUI. * @return {Object} - All resistances ordered by type. */ Resistance.prototype.GetFullResistance = function() { let ret = {}; for (let entityForm in this.template) ret[entityForm] = this.GetResistanceOfForm(entityForm); return ret; }; /** * Get the resistance of a particular type, i.e. Foundation or Entity. * @param {string} entityForm - The form of the entity to query. * @return {Object} - An object containing the resistances. */ Resistance.prototype.GetResistanceOfForm = function(entityForm) { let ret = {}; let template = this.template[entityForm]; if (!template) return ret; if (template.Damage) { ret.Damage = {}; for (let damageType in template.Damage) ret.Damage[damageType] = ApplyValueModificationsToEntity("Resistance/" + entityForm + "/Damage/" + damageType, +this.template[entityForm].Damage[damageType], this.entity); } if (template.Capture) ret.Capture = ApplyValueModificationsToEntity("Resistance/" + entityForm + "/Capture", +this.template[entityForm].Capture, this.entity); + if (template.ApplyStatus) + { + ret.ApplyStatus = {}; + for (let effect in template.ApplyStatus) + ret.ApplyStatus[effect] = { + "duration": ApplyValueModificationsToEntity("Resistance/" + entityForm + "/ApplyStatus/" + effect + "/Duration", +(template.ApplyStatus[effect].Duration || 1), this.entity), + "blockChance": ApplyValueModificationsToEntity("Resistance/" + entityForm + "/ApplyStatus/" + effect + "/BlockChance", +(template.ApplyStatus[effect].BlockChance || 0), this.entity) + }; + } + return ret; }; Engine.RegisterComponentType(IID_Resistance, "Resistance", Resistance); Index: ps/trunk/binaries/data/mods/public/simulation/components/StatusEffectsReceiver.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/StatusEffectsReceiver.js (revision 24161) +++ ps/trunk/binaries/data/mods/public/simulation/components/StatusEffectsReceiver.js (revision 24162) @@ -1,162 +1,165 @@ function StatusEffectsReceiver() {} StatusEffectsReceiver.prototype.DefaultInterval = 1000; /** * Initialises the status effects. */ StatusEffectsReceiver.prototype.Init = function() { this.activeStatusEffects = {}; }; /** * Which status effects are active on this entity. * * @return {Object} - An object containing the status effects which currently affect the entity. */ StatusEffectsReceiver.prototype.GetActiveStatuses = function() { return this.activeStatusEffects; }; /** * Called by Attacking effects. Adds status effects for each entry in the effectData. * * @param {Object} effectData - An object containing the status effects to give to the entity. * @param {number} attacker - The entity ID of the attacker. * @param {number} attackerOwner - The player ID of the attacker. * @param {number} bonusMultiplier - A value to multiply the damage with (not implemented yet for SE). * - * @return {Object} - The names of the status effects which were processed. + * @return {Object} - The codes of the status effects which were processed. */ StatusEffectsReceiver.prototype.ApplyStatus = function(effectData, attacker, attackerOwner) { let attackerData = { "entity": attacker, "owner": attackerOwner }; for (let effect in effectData) this.AddStatus(effect, effectData[effect], attackerData); - // TODO: implement loot / resistance. + // TODO: implement loot? return { "inflictedStatuses": Object.keys(effectData) }; }; /** * Adds a status effect to the entity. * - * @param {string} statusName - The name of the status effect. + * @param {string} statusCode - The code of the status effect. * @param {Object} data - The various effects and timings. * @param {Object} attackerData - The attacker and attackerOwner. */ -StatusEffectsReceiver.prototype.AddStatus = function(statusName, data, attackerData) +StatusEffectsReceiver.prototype.AddStatus = function(baseCode, data, attackerData) { - if (this.activeStatusEffects[statusName]) + let statusCode = baseCode; + if (this.activeStatusEffects[statusCode]) { if (data.Stackability == "Ignore") return; if (data.Stackability == "Extend") { - this.activeStatusEffects[statusName].Duration += data.Duration; + this.activeStatusEffects[statusCode].Duration += data.Duration; return; } if (data.Stackability == "Replace") - this.RemoveStatus(statusName); + this.RemoveStatus(statusCode); else if (data.Stackability == "Stack") { let i = 0; let temp; do - temp = statusName + "_" + i++; + temp = statusCode + "_" + i++; while (!!this.activeStatusEffects[temp]); - statusName = temp; + statusCode = temp; } } - this.activeStatusEffects[statusName] = {}; - let status = this.activeStatusEffects[statusName]; + this.activeStatusEffects[statusCode] = { + "baseCode": baseCode + }; + let status = this.activeStatusEffects[statusCode]; Object.assign(status, data); if (status.Modifiers) { let modifications = DeriveModificationsFromXMLTemplate(status.Modifiers); let cmpModifiersManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ModifiersManager); - cmpModifiersManager.AddModifiers(statusName, modifications, this.entity); + cmpModifiersManager.AddModifiers(statusCode, modifications, this.entity); } // With neither an interval nor a duration, there is no point in starting a timer. if (!status.Duration && !status.Interval) return; // We need this to prevent Status Effects from giving XP // to the entity that applied them. status.StatusEffect = true; // We want an interval to update the GUI to show how much time of the status effect // is left even if the status effect itself has no interval. if (!status.Interval) status._interval = this.DefaultInterval; status._timeElapsed = 0; status._firstTime = true; status.source = attackerData; let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); - status._timer = cmpTimer.SetInterval(this.entity, IID_StatusEffectsReceiver, "ExecuteEffect", 0, +(status.Interval || status._interval), statusName); + status._timer = cmpTimer.SetInterval(this.entity, IID_StatusEffectsReceiver, "ExecuteEffect", 0, +(status.Interval || status._interval), statusCode); }; /** * Removes a status effect from the entity. * - * @param {string} statusName - The status effect to be removed. + * @param {string} statusCode - The status effect to be removed. */ -StatusEffectsReceiver.prototype.RemoveStatus = function(statusName) +StatusEffectsReceiver.prototype.RemoveStatus = function(statusCode) { - let statusEffect = this.activeStatusEffects[statusName]; + let statusEffect = this.activeStatusEffects[statusCode]; if (!statusEffect) return; if (statusEffect.Modifiers) { let cmpModifiersManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ModifiersManager); - cmpModifiersManager.RemoveAllModifiers(statusName, this.entity); + cmpModifiersManager.RemoveAllModifiers(statusCode, this.entity); } if (statusEffect._timer) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); cmpTimer.CancelTimer(statusEffect._timer); } - delete this.activeStatusEffects[statusName]; + delete this.activeStatusEffects[statusCode]; }; /** * Called by the timers. Executes a status effect. * - * @param {string} statusName - The name of the status effect to be executed. + * @param {string} statusCode - The status effect to be executed. * @param {number} lateness - The delay between the calling of the function and the actual execution (turn time?). */ -StatusEffectsReceiver.prototype.ExecuteEffect = function(statusName, lateness) +StatusEffectsReceiver.prototype.ExecuteEffect = function(statusCode, lateness) { - let status = this.activeStatusEffects[statusName]; + let status = this.activeStatusEffects[statusCode]; if (!status) return; if (status.Damage || status.Capture) - Attacking.HandleAttackEffects(this.entity, statusName, status, status.source.entity, status.source.owner); + Attacking.HandleAttackEffects(this.entity, statusCode, status, status.source.entity, status.source.owner); if (!status.Duration) return; if (status._firstTime) { status._firstTime = false; status._timeElapsed += lateness; } else status._timeElapsed += +(status.Interval || status._interval) + lateness; if (status._timeElapsed >= +status.Duration) - this.RemoveStatus(statusName); + this.RemoveStatus(statusCode); }; Engine.RegisterComponentType(IID_StatusEffectsReceiver, "StatusEffectsReceiver", StatusEffectsReceiver); Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Resistance.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Resistance.js (revision 24161) +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_Resistance.js (revision 24162) @@ -1,250 +1,350 @@ Engine.LoadHelperScript("Attacking.js"); Engine.LoadHelperScript("Player.js"); Engine.LoadHelperScript("ValueModification.js"); Engine.LoadComponentScript("interfaces/Capturable.js"); Engine.LoadComponentScript("interfaces/Foundation.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/Looter.js"); Engine.LoadComponentScript("interfaces/ModifiersManager.js"); Engine.LoadComponentScript("interfaces/PlayerManager.js"); Engine.LoadComponentScript("interfaces/Promotion.js"); Engine.LoadComponentScript("interfaces/Resistance.js"); Engine.LoadComponentScript("interfaces/StatisticsTracker.js"); +Engine.LoadComponentScript("interfaces/StatusEffectsReceiver.js"); Engine.LoadComponentScript("Resistance.js"); class testResistance { constructor() { this.cmpResistance = null; this.PlayerID = 1; this.EnemyID = 2; this.EntityID = 3; this.AttackerID = 4; } Reset(schema = {}) { this.cmpResistance = ConstructComponent(this.EntityID, "Resistance", schema); - DeleteMock(this.EntityID, IID_Health); DeleteMock(this.EntityID, IID_Capturable); + DeleteMock(this.EntityID, IID_Health); DeleteMock(this.EntityID, IID_Identity); + DeleteMock(this.EntityID, IID_StatusEffectsReceiver); } TestInvulnerability() { this.Reset(); let damage = 5; let attackData = { "Damage": { "Name": damage } }; let attackType = "Test"; TS_ASSERT(!this.cmpResistance.IsInvulnerable()); let cmpHealth = AddMock(this.EntityID, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, damage); return { "healthChange": -amount }; } }); let spy = new Spy(cmpHealth, "TakeDamage"); Attacking.HandleAttackEffects(this.EntityID, attackType, attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(spy._called, 1); this.cmpResistance.SetInvulnerability(true); TS_ASSERT(this.cmpResistance.IsInvulnerable()); Attacking.HandleAttackEffects(this.EntityID, attackType, attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(spy._called, 1); } TestBonus() { this.Reset(); let damage = 5; let bonus = 2; let classes = "Entity"; let attackData = { "Damage": { "Name": damage }, "Bonuses": { "bonus": { "Classes": classes, "Multiplier": bonus } } }; AddMock(this.EntityID, IID_Identity, { "GetClassesList": () => [classes], "GetCiv": () => "civ" }); let cmpHealth = AddMock(this.EntityID, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, damage * bonus); return { "healthChange": -amount }; } }); let spy = new Spy(cmpHealth, "TakeDamage"); Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(spy._called, 1); } TestDamageResistanceApplies() { let resistanceValue = 2; let damageType = "Name"; this.Reset({ "Entity": { "Damage": { [damageType]: resistanceValue } } }); let damage = 5; let attackData = { "Damage": { "Name": damage } }; let cmpHealth = AddMock(this.EntityID, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, damage * Math.pow(0.9, resistanceValue)); return { "healthChange": -amount }; } }); let spy = new Spy(cmpHealth, "TakeDamage"); Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(spy._called, 1); } TestCaptureResistanceApplies() { let resistanceValue = 2; this.Reset({ "Entity": { "Capture": resistanceValue } }); let damage = 5; let attackData = { "Capture": damage }; let cmpCapturable = AddMock(this.EntityID, IID_Capturable, { "Capture": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, damage * Math.pow(0.9, resistanceValue)); return { "captureChange": amount }; } }); let spy = new Spy(cmpCapturable, "Capture"); Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(spy._called, 1); } + TestStatusEffectsResistancesApplies() + { + // Test duration reduction. + let durationFactor = 0.5; + let statusName = "statusName"; + this.Reset({ + "Entity": { + "ApplyStatus": { + [statusName]: { + "Duration": durationFactor + } + } + } + }); + + let duration = 10; + let attackData = { + "ApplyStatus": { + [statusName]: { + "Duration": duration + } + } + }; + + let cmpStatusEffectsReceiver = AddMock(this.EntityID, IID_StatusEffectsReceiver, { + "ApplyStatus": (effectData, __, ___) => { + TS_ASSERT_EQUALS(effectData[statusName].Duration, duration * durationFactor); + return { "inflictedStatuses": Object.keys(effectData) }; + } + }); + let spy = new Spy(cmpStatusEffectsReceiver, "ApplyStatus"); + + Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); + TS_ASSERT_EQUALS(spy._called, 1); + + // Test blocking. + this.Reset({ + "Entity": { + "ApplyStatus": { + [statusName]: { + "BlockChance": "1" + } + } + } + }); + + cmpStatusEffectsReceiver = AddMock(this.EntityID, IID_StatusEffectsReceiver, { + "ApplyStatus": (effectData, __, ___) => { + TS_ASSERT_UNEVAL_EQUALS(effectData, {}); + return { "inflictedStatuses": Object.keys(effectData) }; + } + }); + spy = new Spy(cmpStatusEffectsReceiver, "ApplyStatus"); + + Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); + TS_ASSERT_EQUALS(spy._called, 1); + + // Test multiple resistances. + let reducedStatusName = "reducedStatus"; + let blockedStatusName = "blockedStatus"; + this.Reset({ + "Entity": { + "ApplyStatus": { + [reducedStatusName]: { + "Duration": durationFactor + }, + [blockedStatusName]: { + "BlockChance": "1" + } + } + } + }); + + attackData = { + "ApplyStatus": { + [reducedStatusName]: { + "Duration": duration + }, + [blockedStatusName]: { + "Duration": duration + } + } + }; + + cmpStatusEffectsReceiver = AddMock(this.EntityID, IID_StatusEffectsReceiver, { + "ApplyStatus": (effectData, __, ___) => { + TS_ASSERT_EQUALS(effectData[reducedStatusName].Duration, duration * durationFactor); + TS_ASSERT_UNEVAL_EQUALS(Object.keys(effectData), [reducedStatusName]); + return { "inflictedStatuses": Object.keys(effectData) }; + } + }); + spy = new Spy(cmpStatusEffectsReceiver, "ApplyStatus"); + + Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); + TS_ASSERT_EQUALS(spy._called, 1); + } + TestResistanceAndBonus() { let resistanceValue = 2; let damageType = "Name"; this.Reset({ "Entity": { "Damage": { [damageType]: resistanceValue } } }); let damage = 5; let bonus = 2; let classes = "Entity"; let attackData = { "Damage": { "Name": damage }, "Bonuses": { "bonus": { "Classes": classes, "Multiplier": bonus } } }; AddMock(this.EntityID, IID_Identity, { "GetClassesList": () => [classes], "GetCiv": () => "civ" }); let cmpHealth = AddMock(this.EntityID, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, damage * bonus * Math.pow(0.9, resistanceValue)); return { "healthChange": -amount }; } }); let spy = new Spy(cmpHealth, "TakeDamage"); Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(spy._called, 1); } TestMultipleEffects() { let captureResistanceValue = 2; this.Reset({ "Entity": { "Capture": captureResistanceValue } }); let damage = 5; let bonus = 2; let classes = "Entity"; let attackData = { "Damage": { "Name": damage }, "Capture": damage, "Bonuses": { "bonus": { "Classes": classes, "Multiplier": bonus } } }; AddMock(this.EntityID, IID_Identity, { "GetClassesList": () => [classes], "GetCiv": () => "civ" }); let cmpCapturable = AddMock(this.EntityID, IID_Capturable, { "Capture": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, damage * bonus * Math.pow(0.9, captureResistanceValue)); return { "captureChange": amount }; } }); let cmpHealth = AddMock(this.EntityID, IID_Health, { "TakeDamage": (amount, __, ___) => { TS_ASSERT_EQUALS(amount, damage * bonus); return { "healthChange": -amount }; }, "GetHitpoints": () => 1, "GetMaxHitpoints": () => 1 }); let healthSpy = new Spy(cmpHealth, "TakeDamage"); let captureSpy = new Spy(cmpCapturable, "Capture"); Attacking.HandleAttackEffects(this.EntityID, "Test", attackData, this.AttackerID, this.EnemyID); TS_ASSERT_EQUALS(healthSpy._called, 1); TS_ASSERT_EQUALS(captureSpy._called, 1); } } let cmp = new testResistance(); cmp.TestInvulnerability(); cmp.TestBonus(); cmp.TestDamageResistanceApplies(); cmp.TestCaptureResistanceApplies(); +cmp.TestStatusEffectsResistancesApplies(); cmp.TestResistanceAndBonus(); cmp.TestMultipleEffects(); Index: ps/trunk/binaries/data/mods/public/simulation/helpers/Attacking.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/helpers/Attacking.js (revision 24161) +++ ps/trunk/binaries/data/mods/public/simulation/helpers/Attacking.js (revision 24162) @@ -1,373 +1,383 @@ /** * Provides attack and damage-related helpers under the Attacking umbrella (to avoid name ambiguity with the component). */ function Attacking() {} const DirectEffectsSchema = "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; const StatusEffectsSchema = "" + "" + "" + - "" + + "" + "" + "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + "" + "" + "" + "" + "" + "" + "" + DirectEffectsSchema + "" + "" + "" + "" + "" + ModificationsSchema + "" + "" + "" + "Ignore" + "Extend" + "Replace" + "Stack" + "" + "" + "" + "" + "" + ""; /** * Builds a RelaxRNG schema of possible attack effects. * See globalscripts/AttackEffects.js for possible elements. * Attacks may also have a "Bonuses" element. * * @return {string} - RelaxNG schema string. */ Attacking.prototype.BuildAttackEffectsSchema = function() { return "" + "" + "" + DirectEffectsSchema + StatusEffectsSchema + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; }; /** * Returns a template-like object of attack effects. */ Attacking.prototype.GetAttackEffectsData = function(valueModifRoot, template, entity) { let ret = {}; if (template.Damage) { ret.Damage = {}; let applyMods = damageType => ApplyValueModificationsToEntity(valueModifRoot + "/Damage/" + damageType, +(template.Damage[damageType] || 0), entity); for (let damageType in template.Damage) ret.Damage[damageType] = applyMods(damageType); } if (template.Capture) ret.Capture = ApplyValueModificationsToEntity(valueModifRoot + "/Capture", +(template.Capture || 0), entity); if (template.ApplyStatus) ret.ApplyStatus = this.GetStatusEffectsData(valueModifRoot, template.ApplyStatus, entity); if (template.Bonuses) ret.Bonuses = template.Bonuses; return ret; }; Attacking.prototype.GetStatusEffectsData = function(valueModifRoot, template, entity) { let result = {}; for (let effect in template) { let statusTemplate = template[effect]; result[effect] = { "StatusName": statusTemplate.StatusName, "ApplierTooltip": statusTemplate.ApplierTooltip, "ReceiverTooltip": statusTemplate.ReceiverTooltip, "Duration": ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Duration", +(statusTemplate.Duration || 0), entity), "Interval": ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Interval", +(statusTemplate.Interval || 0), entity), "Stackability": statusTemplate.Stackability }; Object.assign(result[effect], this.GetAttackEffectsData(valueModifRoot + "/ApplyStatus" + effect, statusTemplate, entity)); if (statusTemplate.Modifiers) result[effect].Modifiers = this.GetStatusEffectsModifications(valueModifRoot, statusTemplate.Modifiers, entity, effect); } return result; }; Attacking.prototype.GetStatusEffectsModifications = function(valueModifRoot, template, entity, effect) { let modifiers = {}; for (let modifier in template) { let modifierTemplate = template[modifier]; modifiers[modifier] = { "Paths": modifierTemplate.Paths, "Affects": modifierTemplate.Affects }; if (modifierTemplate.Add !== undefined) modifiers[modifier].Add = ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Modifiers/" + modifier + "/Add", +modifierTemplate.Add, entity); if (modifierTemplate.Multiply !== undefined) modifiers[modifier].Multiply = ApplyValueModificationsToEntity(valueModifRoot + "/ApplyStatus/" + effect + "/Modifiers/" + modifier + "/Multiply", +modifierTemplate.Multiply, entity); if (modifierTemplate.Replace !== undefined) modifiers[modifier].Replace = modifierTemplate.Replace; } return modifiers; }; /** * Calculate the total effect taking bonus and resistance into account. * * @param {number} target - The target of the attack. * @param {Object} effectData - The effects calculate the effect for. * @param {string} effectType - The type of effect to apply (e.g. Damage, Capture or ApplyStatus). * @param {number} bonusMultiplier - The factor to multiply the total effect with. * @param {Object} cmpResistance - Optionally the resistance component of the target. * * @return {number} - The total value of the effect. */ Attacking.prototype.GetTotalAttackEffects = function(target, effectData, effectType, bonusMultiplier, cmpResistance) { let total = 0; if (!cmpResistance) cmpResistance = Engine.QueryInterface(target, IID_Resistance); let resistanceStrengths = cmpResistance ? cmpResistance.GetEffectiveResistanceAgainst(effectType) : {}; if (effectType == "Damage") for (let type in effectData.Damage) total += effectData.Damage[type] * Math.pow(0.9, resistanceStrengths.Damage ? resistanceStrengths.Damage[type] || 0 : 0); else if (effectType == "Capture") { total = effectData.Capture * Math.pow(0.9, resistanceStrengths.Capture || 0); // If Health is lower we are more susceptible to capture attacks. let cmpHealth = Engine.QueryInterface(target, IID_Health); if (cmpHealth) total /= 0.1 + 0.9 * cmpHealth.GetHitpoints() / cmpHealth.GetMaxHitpoints(); } - else if (effectType == "ApplyStatus") + if (effectType != "ApplyStatus") + return total * bonusMultiplier; + + if (!resistanceStrengths.ApplyStatus) return effectData[effectType]; - return total * bonusMultiplier; + let result = {}; + for (let statusEffect in effectData[effectType]) + { + if (!resistanceStrengths.ApplyStatus[statusEffect]) + { + result[statusEffect] = effectData[effectType][statusEffect]; + continue; + } + + if (randBool(resistanceStrengths.ApplyStatus[statusEffect].blockChance)) + continue; + + result[statusEffect] = effectData[effectType][statusEffect]; + + if (effectData[effectType][statusEffect].Duration) + result[statusEffect].Duration = effectData[effectType][statusEffect].Duration * + resistanceStrengths.ApplyStatus[statusEffect].duration; + } + return result; + }; /** * Get the list of players affected by the damage. * @param {number} attackerOwner - The player id of the attacker. * @param {boolean} friendlyFire - A flag indicating if allied entities are also damaged. * @return {number[]} The ids of players need to be damaged. */ Attacking.prototype.GetPlayersToDamage = function(attackerOwner, friendlyFire) { if (!friendlyFire) return QueryPlayerIDInterface(attackerOwner).GetEnemies(); return Engine.QueryInterface(SYSTEM_ENTITY, IID_PlayerManager).GetAllPlayers(); }; /** * Damages units around a given origin. * @param {Object} data - The data sent by the caller. * @param {string} data.type - The type of damage. * @param {Object} data.attackData - The attack data. * @param {number} data.attacker - The entity id of the attacker. * @param {number} data.attackerOwner - The player id of the attacker. * @param {Vector2D} data.origin - The origin of the projectile hit. * @param {number} data.radius - The radius of the splash damage. * @param {string} data.shape - The shape of the radius. * @param {Vector3D} [data.direction] - The unit vector defining the direction. Needed for linear splash damage. * @param {boolean} data.friendlyFire - A flag indicating if allied entities also ought to be damaged. */ Attacking.prototype.CauseDamageOverArea = function(data) { let nearEnts = PositionHelper.EntitiesNearPoint(data.origin, data.radius, this.GetPlayersToDamage(data.attackerOwner, data.friendlyFire)); let damageMultiplier = 1; let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); // Cycle through all the nearby entities and damage it appropriately based on its distance from the origin. for (let ent of nearEnts) { // Correct somewhat for the entity's obstruction radius. // TODO: linear falloff should arguably use something cleverer. let distance = cmpObstructionManager.DistanceToPoint(ent, data.origin.x, data.origin.y); if (data.shape == 'Circular') // circular effect with quadratic falloff in every direction damageMultiplier = 1 - distance * distance / (data.radius * data.radius); else if (data.shape == 'Linear') // linear effect with quadratic falloff in two directions (only used for certain missiles) { // The entity has a position here since it was returned by the range manager. let entityPosition = Engine.QueryInterface(ent, IID_Position).GetPosition2D(); let relativePos = entityPosition.sub(data.origin).normalize().mult(distance); // Get the position relative to the missile direction. let direction = Vector2D.from3D(data.direction); let parallelPos = relativePos.dot(direction); let perpPos = relativePos.cross(direction); // The width of linear splash is one fifth of the normal splash radius. let width = data.radius / 5; // Check that the unit is within the distance splash width of the line starting at the missile's // landing point which extends in the direction of the missile for length splash radius. if (parallelPos >= 0 && Math.abs(perpPos) < width) // If in radius, quadratic falloff in both directions damageMultiplier = (1 - parallelPos * parallelPos / (data.radius * data.radius)) * (1 - perpPos * perpPos / (width * width)); else damageMultiplier = 0; } else // In case someone calls this function with an invalid shape. { warn("The " + data.shape + " splash damage shape is not implemented!"); } // The RangeManager can return units that are too far away (due to approximations there) // so the multiplier can end up below 0. damageMultiplier = Math.max(0, damageMultiplier); this.HandleAttackEffects(ent, data.type + ".Splash", data.attackData, data.attacker, data.attackerOwner, damageMultiplier); } }; /** * Handle an attack peformed on an entity. * * @param {number} target - The targetted entityID. * @param {string} attackType - The type of attack that was performed (e.g. "Melee" or "Capture"). * @param {Object} effectData - The effects use. * @param {number} attacker - The entityID that attacked us. * @param {number} attackerOwner - The playerID that owned the attacker when the attack was performed. * @param {number} bonusMultiplier - The factor to multiply the total effect with, defaults to 1. * * @return {boolean} - Whether we handled the attack. */ Attacking.prototype.HandleAttackEffects = function(target, attackType, attackData, attacker, attackerOwner, bonusMultiplier = 1) { let cmpResistance = Engine.QueryInterface(target, IID_Resistance); if (cmpResistance && cmpResistance.IsInvulnerable()) return false; bonusMultiplier *= !attackData.Bonuses ? 1 : this.GetAttackBonus(attacker, target, attackType, attackData.Bonuses); let targetState = {}; for (let effectType of g_EffectTypes) { if (!attackData[effectType]) continue; let receiver = g_EffectReceiver[effectType]; let cmpReceiver = Engine.QueryInterface(target, global[receiver.IID]); if (!cmpReceiver) continue; Object.assign(targetState, cmpReceiver[receiver.method](this.GetTotalAttackEffects(target, attackData, effectType, bonusMultiplier, cmpResistance), attacker, attackerOwner)); } if (!Object.keys(targetState).length) return false; Engine.PostMessage(target, MT_Attacked, { "type": attackType, "target": target, "attacker": attacker, "attackerOwner": attackerOwner, "damage": -(targetState.healthChange || 0), "capture": targetState.captureChange || 0, "statusEffects": targetState.inflictedStatuses || [], "fromStatusEffect": !!attackData.StatusEffect, }); // We do not want an entity to get XP from active Status Effects. if (!!attackData.StatusEffect) return true; let cmpPromotion = Engine.QueryInterface(attacker, IID_Promotion); if (cmpPromotion && targetState.xp) cmpPromotion.IncreaseXp(targetState.xp); return true; }; /** * Calculates the attack damage multiplier against a target. * @param {number} source - The source entity's id. * @param {number} target - The target entity's id. * @param {string} type - The type of attack. * @param {Object} template - The bonus' template. * @return {number} - The source entity's attack bonus against the specified target. */ Attacking.prototype.GetAttackBonus = function(source, target, type, template) { let cmpIdentity = Engine.QueryInterface(target, IID_Identity); if (!cmpIdentity) return 1; let attackBonus = 1; let targetClasses = cmpIdentity.GetClassesList(); let targetCiv = cmpIdentity.GetCiv(); // Multiply the bonuses for all matching classes. for (let key in template) { let bonus = template[key]; if (bonus.Civ && bonus.Civ !== targetCiv) continue; if (!bonus.Classes || MatchesClassList(targetClasses, bonus.Classes)) attackBonus *= ApplyValueModificationsToEntity("Attack/" + type + "/Bonuses/" + key + "/Multiplier", +bonus.Multiplier, source); } return attackBonus; }; var AttackingInstance = new Attacking(); Engine.RegisterGlobal("Attacking", AttackingInstance);