diff --git a/src/RealTime/Core/RealTimeCore.cs b/src/RealTime/Core/RealTimeCore.cs index 6648a7246d7bd6116513718f7be2a83091411bc6..02e28d914ed56ea00fe3f99812c5cd7a8ded8da3 100644 --- a/src/RealTime/Core/RealTimeCore.cs +++ b/src/RealTime/Core/RealTimeCore.cs @@ -143,6 +143,17 @@ namespace RealTime.Core var result = new RealTimeCore(timeAdjustment, customTimeBar, eventManager, patcher); eventManager.EventsChanged += result.CityEventsChanged; + + var statistics = new Statistics(timeInfo, localizationProvider); + if (statistics.Initialize()) + { + statistics.RefreshUnits(); + } + else + { + statistics = null; + } + SimulationHandler.NewDay += result.CityEventsChanged; SimulationHandler.TimeAdjustment = timeAdjustment; @@ -152,6 +163,7 @@ namespace RealTime.Core SimulationHandler.Buildings = BuildingAIPatches.RealTimeAI; SimulationHandler.Buildings.UpdateFrameDuration(); SimulationHandler.Buildings.InitializeLightState(); + SimulationHandler.Statistics = statistics; AwakeSleepSimulation.Install(configProvider.Configuration); @@ -205,6 +217,8 @@ namespace RealTime.Core SimulationHandler.WeatherInfo = null; SimulationHandler.Buildings = null; SimulationHandler.CitizenProcessor = null; + SimulationHandler.Statistics?.Close(); + SimulationHandler.Statistics = null; isEnabled = false; } diff --git a/src/RealTime/Localization/Constants.cs b/src/RealTime/Localization/Constants.cs index 908c5080c10d03e8a5ab8f2f9e74979e5a8168a8..ed3c0e781ed83aef9c51062d86eeb4fd0f23344e 100644 --- a/src/RealTime/Localization/Constants.cs +++ b/src/RealTime/Localization/Constants.cs @@ -20,7 +20,16 @@ namespace RealTime.Localization /// The XML item key attribute name. public const string XmlKeyAttribute = "id"; + /// The XML translation node name. + public const string XmlTranslationNodeName = "translation"; + /// The XML item value attribute name. public const string XmlValueAttribute = "value"; + + /// The XML override node name. + public const string XmlOverrideNodeName = "overrides"; + + /// The XML override node 'type' attribute name. + public const string XmlOverrideTypeAttribute = "type"; } } \ No newline at end of file diff --git a/src/RealTime/Localization/ILocalizationProvider.cs b/src/RealTime/Localization/ILocalizationProvider.cs index d9dbc24042060815493db2e9213fa8806c53ee6f..95f514a78783da015dee3478aac5dba645938c5e 100644 --- a/src/RealTime/Localization/ILocalizationProvider.cs +++ b/src/RealTime/Localization/ILocalizationProvider.cs @@ -4,6 +4,7 @@ namespace RealTime.Localization { + using System.Collections.Generic; using System.Globalization; /// @@ -18,5 +19,12 @@ namespace RealTime.Localization /// The value ID. /// The translated string value or the placeholder text on failure. string Translate(string id); + + /// Gets a dictionary representing the game's translations that should be overridden + /// by this mod. Can return null. + /// The overridden translations type string. + /// A map of key-value pairs for translations to override, or null. + /// Thrown when the argument is null. + IDictionary GetOverriddenTranslations(string type); } } \ No newline at end of file diff --git a/src/RealTime/Localization/LocalizationProvider.cs b/src/RealTime/Localization/LocalizationProvider.cs index 7b00e90060eebb88101e8423f2d9c639a3785d38..91992c3006e13b9313f9ce29a6c43eec09cb7fed 100644 --- a/src/RealTime/Localization/LocalizationProvider.cs +++ b/src/RealTime/Localization/LocalizationProvider.cs @@ -15,6 +15,7 @@ namespace RealTime.Localization { private readonly string localeStorage; private readonly Dictionary translation = new Dictionary(); + private readonly Dictionary> overrides = new Dictionary>(); /// Initializes a new instance of the class. /// The root path. @@ -72,6 +73,22 @@ namespace RealTime.Localization return result == LoadingResult.Success; } + /// Gets a dictionary representing the game's translations that should be overridden + /// by this mod. Can return null. + /// The overridden translations type string. + /// A map of key-value pairs for translations to override, or null. + /// Thrown when the argument is null. + public IDictionary GetOverriddenTranslations(string type) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + overrides.TryGetValue(type, out Dictionary result); + return result; + } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Justification = "No security issues here")] private static string GetLocaleNameFromLanguage(string language) { @@ -115,6 +132,7 @@ namespace RealTime.Localization } translation.Clear(); + overrides.Clear(); string path = Path.Combine(localeStorage, language + FileExtension); if (!File.Exists(path)) @@ -132,17 +150,50 @@ namespace RealTime.Localization foreach (XmlNode node in doc.DocumentElement.ChildNodes) { - translation[node.Attributes[XmlKeyAttribute].Value] = node.Attributes[XmlValueAttribute].Value; + switch (node.Name) + { + case XmlTranslationNodeName: + translation[node.Attributes[XmlKeyAttribute].Value] = node.Attributes[XmlValueAttribute].Value; + break; + + case XmlOverrideNodeName when node.HasChildNodes: + ReadOverrides(node); + break; + } } } catch (Exception ex) { Log.Error($"The 'Real Time' cannot load data from localization file '{path}', error message: {ex}"); translation.Clear(); + overrides.Clear(); return LoadingResult.Failure; } return LoadingResult.Success; } + + private void ReadOverrides(XmlNode overridesNode) + { + string type = overridesNode.Attributes[XmlOverrideTypeAttribute]?.Value; + if (type == null) + { + return; + } + + if (!overrides.TryGetValue(type, out Dictionary typeOverrides)) + { + typeOverrides = new Dictionary(); + overrides[type] = typeOverrides; + } + + foreach (XmlNode node in overridesNode.ChildNodes) + { + if (node.Name == XmlTranslationNodeName) + { + typeOverrides[node.Attributes[XmlKeyAttribute].Value] = node.Attributes[XmlValueAttribute].Value; + } + } + } } } \ No newline at end of file diff --git a/src/RealTime/Localization/TranslationKeys.cs b/src/RealTime/Localization/TranslationKeys.cs index b7de428f946d32b0c3dc53872cc6ffab3f83f9da..03c007ec5ff07522d8e49ae93524db0f7f18ffdb 100644 --- a/src/RealTime/Localization/TranslationKeys.cs +++ b/src/RealTime/Localization/TranslationKeys.cs @@ -20,5 +20,8 @@ namespace RealTime.Localization /// The key for a 'incompatible mods found' message. public const string IncompatibleModsFoundMessage = "IncompatibleModsFoundMessage"; + + /// The key for the abbreviated 'minutes' text. + public const string Minutes = "Minutes"; } } diff --git a/src/RealTime/Localization/Translations/de.xml b/src/RealTime/Localization/Translations/de.xml index a65295b0143283664742f3fce89511fb52d5b52b..260b4fd8598e78f68e6fd487a2c2b4233cc46e47 100644 --- a/src/RealTime/Localization/Translations/de.xml +++ b/src/RealTime/Localization/Translations/de.xml @@ -4,6 +4,7 @@ + @@ -80,5 +81,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/RealTime/Localization/Translations/en.xml b/src/RealTime/Localization/Translations/en.xml index 7b486b3560618c70ae33f481681a38f1791c8832..685cb45b0e63e46c41711dfd82a7560d8d8e592b 100644 --- a/src/RealTime/Localization/Translations/en.xml +++ b/src/RealTime/Localization/Translations/en.xml @@ -4,6 +4,7 @@ + @@ -80,5 +81,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/RealTime/Localization/Translations/es.xml b/src/RealTime/Localization/Translations/es.xml index 3bf4d112352b0dea8f20d28f1a616d9fca5e1f44..0da60bbe4817b0554c271f2a6d52d417b1bc39c0 100644 --- a/src/RealTime/Localization/Translations/es.xml +++ b/src/RealTime/Localization/Translations/es.xml @@ -4,6 +4,7 @@ + @@ -80,5 +81,94 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RealTime/Localization/Translations/fr.xml b/src/RealTime/Localization/Translations/fr.xml index 85ab630b76e8ccb45369d51c7328dad7d94dc5a7..1d61b273f70012643d9bbf1cba0f44c23d3c476a 100644 --- a/src/RealTime/Localization/Translations/fr.xml +++ b/src/RealTime/Localization/Translations/fr.xml @@ -4,6 +4,7 @@ + @@ -80,5 +81,94 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RealTime/Localization/Translations/ko.xml b/src/RealTime/Localization/Translations/ko.xml index 3204123759e4b2f6c319e02629007471e7cfdf7b..6c627ae55b4d71c955795b77c0726999c8ccd1f5 100644 --- a/src/RealTime/Localization/Translations/ko.xml +++ b/src/RealTime/Localization/Translations/ko.xml @@ -4,7 +4,8 @@ - + + @@ -80,5 +81,94 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/RealTime/Localization/Translations/pl.xml b/src/RealTime/Localization/Translations/pl.xml index d9ca31fc82397da7dff7d1f32efef66a781ef8a2..4cddfc6f6d7f7b91a8081df6adcef70b0b89fb34 100644 --- a/src/RealTime/Localization/Translations/pl.xml +++ b/src/RealTime/Localization/Translations/pl.xml @@ -4,6 +4,7 @@ + @@ -80,5 +81,94 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/RealTime/Localization/Translations/pt.xml b/src/RealTime/Localization/Translations/pt.xml index ce97e47af3a7b0f69705411fe328475a1e6c722f..2e955ddc1c901a87ba067f475f8f14e3942ad215 100644 --- a/src/RealTime/Localization/Translations/pt.xml +++ b/src/RealTime/Localization/Translations/pt.xml @@ -4,6 +4,7 @@ + @@ -80,5 +81,94 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RealTime/Localization/Translations/ru.xml b/src/RealTime/Localization/Translations/ru.xml index 4a66c77181f74ef2e9372323d4c947a665ef1b20..55dd3df3450e3f767af83c412df3d10549a1ff9b 100644 --- a/src/RealTime/Localization/Translations/ru.xml +++ b/src/RealTime/Localization/Translations/ru.xml @@ -4,6 +4,7 @@ + @@ -80,5 +81,94 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/RealTime/Localization/Translations/zh.xml b/src/RealTime/Localization/Translations/zh.xml index 4e9d73a24858574b28e35f212de75f7488204d54..c637e6a3cde098d912f92c7ea656576ee6ab7e92 100644 --- a/src/RealTime/Localization/Translations/zh.xml +++ b/src/RealTime/Localization/Translations/zh.xml @@ -4,6 +4,7 @@ + @@ -80,5 +81,94 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/RealTime/RealTime.csproj b/src/RealTime/RealTime.csproj index e0bba36440ddf16ded2bfe1d1a51f8928c0ed1c4..27346c64f9ff082e9b844f0d608a6246ea9dfaf4 100644 --- a/src/RealTime/RealTime.csproj +++ b/src/RealTime/RealTime.csproj @@ -139,6 +139,7 @@ + diff --git a/src/RealTime/Simulation/SimulationHandler.cs b/src/RealTime/Simulation/SimulationHandler.cs index 92dc4b84c45b298c45258f2903bea77cd5dd9dbe..8509065bea97d1ebae3b1a6b5b87c6ed58d78112 100644 --- a/src/RealTime/Simulation/SimulationHandler.cs +++ b/src/RealTime/Simulation/SimulationHandler.cs @@ -46,6 +46,9 @@ namespace RealTime.Simulation /// Gets or sets the citizen processing class instance. internal static CitizenProcessor CitizenProcessor { get; set; } + /// Gets or sets the statistics processing class instance. + internal static Statistics Statistics { get; set; } + /// /// Called before each game simulation tick. A tick contains multiple frames. /// Performs the dispatching for this simulation phase. @@ -70,6 +73,7 @@ namespace RealTime.Simulation if (updateFrameLength) { Buildings?.UpdateFrameDuration(); + Statistics?.RefreshUnits(); } if (DayTimeSimulation == null || CitizenProcessor == null) diff --git a/src/RealTime/Simulation/Statistics.cs b/src/RealTime/Simulation/Statistics.cs new file mode 100644 index 0000000000000000000000000000000000000000..9942d7c911d34f2543b39a5a470c65404ef4c37e --- /dev/null +++ b/src/RealTime/Simulation/Statistics.cs @@ -0,0 +1,260 @@ +// +// Copyright (c) dymanoid. All rights reserved. +// + +namespace RealTime.Simulation +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + using ColossalFramework.Globalization; + using ColossalFramework.UI; + using RealTime.Localization; + using RealTime.Tools; + using UnityEngine; + + /// + /// Handles the customization of the game's statistics numbers. + /// + internal sealed class Statistics + { + private const int VanillaFramesPerWeek = 3840; + private const string UnitPlaceholder = "{unit}"; + private const string OverrddenTranslationType = "Units"; + private const string CityInfoPanelName = "(Library) CityInfoPanel"; + private const string DistrictInfoPanelName = "(Library) DistrictWorldInfoPanel"; + private const string TouristsPanelName = "Tourists"; + private const string TouristsLabelName = "Label"; + private const string InfoPanelName = "InfoPanel"; + private const string CitizensChangeLabelName = "ProjectedChange"; + private const string IncomeLabelName = "ProjectedIncome"; + private const string BuildingsButtonsContainer = "TSContainer"; + + private readonly ITimeInfo timeInfo; + private readonly ILocalizationProvider localizationProvider; + private readonly Locale customLocale; + + private Locale mainLocale; + private UILabel cityInfoPanelTourists; + private UILabel districtInfoPanelTourists; + private UILabel labelPopulation; + private UILabel labelIncome; + private UITabContainer buildingsTabContainer; + + /// Initializes a new instance of the class. + /// An object that provides the game's time information. + /// The object to get the current locale from. + /// Thrown when any argument is null. + public Statistics(ITimeInfo timeInfo, ILocalizationProvider localizationProvider) + { + this.timeInfo = timeInfo ?? throw new ArgumentNullException(nameof(timeInfo)); + this.localizationProvider = localizationProvider ?? throw new ArgumentNullException(nameof(localizationProvider)); + + customLocale = new Locale(); + } + + /// Initializes this instance by preparing connections to the necessary game parts. + /// true on success; otherwise, false. + public bool Initialize() + { + if (mainLocale != null) + { + return true; + } + + try + { + FieldInfo field = typeof(LocaleManager).GetField("m_Locale", BindingFlags.Instance | BindingFlags.NonPublic); + mainLocale = field.GetValue(LocaleManager.instance) as Locale; + } + catch (Exception ex) + { + Log.Warning("The 'Real Time' mod could not obtain the locale field of the LocaleManager, error message: " + ex); + return false; + } + + cityInfoPanelTourists = GameObject + .Find(CityInfoPanelName)? + .GetComponent()? + .Find(TouristsPanelName)? + .Find(TouristsLabelName); + + if (cityInfoPanelTourists == null) + { + Log.Warning("The 'Real Time' mod could not obtain the CityInfoPanel.Tourists.Label object"); + } + + districtInfoPanelTourists = GameObject + .Find(DistrictInfoPanelName)? + .GetComponent()? + .Find(TouristsPanelName)? + .Find(TouristsLabelName); + + if (districtInfoPanelTourists == null) + { + Log.Warning("The 'Real Time' mod could not obtain the DistrictWorldInfoPanel.Tourists.Label object"); + } + + UIPanel infoPanel = UIView.Find(InfoPanelName); + if (infoPanel == null) + { + Log.Warning("The 'Real Time' mod could not obtain the InfoPanel object"); + } + else + { + labelPopulation = infoPanel.Find(CitizensChangeLabelName); + if (labelPopulation == null) + { + Log.Warning("The 'Real Time' mod could not obtain the ProjectedChange object"); + } + + labelIncome = infoPanel.Find(IncomeLabelName); + if (labelIncome == null) + { + Log.Warning("The 'Real Time' mod could not obtain the ProjectedIncome object"); + } + } + + buildingsTabContainer = UIView.Find(BuildingsButtonsContainer); + if (buildingsTabContainer == null) + { + Log.Warning("The 'Real Time' mod could not obtain the TSContainer object"); + } + + LocaleManager.eventLocaleChanged += LocaleChanged; + return true; + } + + /// Shuts down this instance. + public void Close() + { + LocaleManager.eventLocaleChanged -= LocaleChanged; + mainLocale = null; + cityInfoPanelTourists = null; + districtInfoPanelTourists = null; + labelIncome = null; + labelPopulation = null; + buildingsTabContainer = null; + } + + /// Refreshes the statistics units for current game speed. + public void RefreshUnits() + { + if (mainLocale == null) + { + return; + } + + var unit = TimeSpan.FromHours(VanillaFramesPerWeek * timeInfo.HoursPerFrame); + + double minutes = Math.Round(unit.TotalMinutes); + if (minutes >= 30d) + { + minutes = Math.Round(minutes / 10d) * 10d; + } + else if (minutes >= 10d) + { + minutes = Math.Round(minutes / 5d) * 5d; + } + + string displayUnit = $"{minutes:F0} {localizationProvider.Translate(TranslationKeys.Minutes)}"; + if (RefreshUnits(displayUnit)) + { + RefreshUI(); + } + } + + private static void RefreshEconomyPanel() + { + IEnumerable components = ToolsModifierControl.economyPanel? + .GetComponentsInChildren()? + .Where(c => !string.IsNullOrEmpty(c.tooltipLocaleID)); + + if (components == null) + { + return; + } + + foreach (UIComponent component in components.Where(c => c is UISprite || c is UITextComponent)) + { + component.tooltip = Locale.Get(component.tooltipLocaleID); + } + } + + private bool RefreshUnits(string displayUnit) + { + customLocale.Reset(); + + IDictionary overridden = localizationProvider.GetOverriddenTranslations(OverrddenTranslationType); + if (overridden == null || overridden.Count == 0) + { + return false; + } + + foreach (KeyValuePair value in overridden) + { + string translated = value.Value.Replace(UnitPlaceholder, displayUnit); + customLocale.AddLocalizedString(new Locale.Key { m_Identifier = value.Key }, translated); + } + + mainLocale.Merge(null, customLocale); + return true; + } + + private void RefreshUI() + { + if (labelIncome != null) + { + labelIncome.tooltip = Locale.Get(labelIncome.tooltipLocaleID); + } + + if (labelPopulation != null) + { + labelPopulation.tooltip = Locale.Get(labelPopulation.tooltipLocaleID); + } + + if (cityInfoPanelTourists != null) + { + cityInfoPanelTourists.text = Locale.Get(cityInfoPanelTourists.localeID); + } + + if (districtInfoPanelTourists != null) + { + districtInfoPanelTourists.text = Locale.Get(districtInfoPanelTourists.localeID); + } + + RefreshEconomyPanel(); + RefreshBuildingsButtons(); + } + + private void RefreshBuildingsButtons() + { + if (buildingsTabContainer == null) + { + return; + } + + // This creates objects on heap, but it won't cause memory pressure because it's not called + // in the simulation loop + var items = buildingsTabContainer.GetComponentsInChildren()? + .Select(b => new { Info = b.objectUserData as PrefabInfo, Button = b }) + .Where(i => i.Info is BuildingInfo || i.Info is NetInfo); + + if (items == null) + { + return; + } + + foreach (var item in items) + { + item.Button.tooltip = item.Info.GetLocalizedTooltip(); + } + } + + private void LocaleChanged() + { + RefreshUnits(); + } + } +} diff --git a/src/RealTime/UI/TitleBar.cs b/src/RealTime/UI/TitleBar.cs index cbf9988c57a317923c77d4b9ab04cc591eab6bd1..e54dc88643db603ccf5fa0bf1f2b4b887478d688 100644 --- a/src/RealTime/UI/TitleBar.cs +++ b/src/RealTime/UI/TitleBar.cs @@ -24,11 +24,7 @@ namespace RealTime.UI /// Gets or sets the title bar caption. public string Caption { - get - { - return captionLabel?.text ?? caption ?? string.Empty; - } - + get => captionLabel?.text ?? caption ?? string.Empty; set { caption = value;