diff --git a/src/RealTime/Config/RealTimeConfig.cs b/src/RealTime/Config/RealTimeConfig.cs index 533e4ddb..c6735f13 100644 --- a/src/RealTime/Config/RealTimeConfig.cs +++ b/src/RealTime/Config/RealTimeConfig.cs @@ -17,7 +17,7 @@ public sealed class RealTimeConfig : IConfiguration /// The storage ID for the configuration objects. public const string StorageId = "RealTimeConfiguration"; - private const int LatestVersion = 2; + private const int LatestVersion = 3; /// Initializes a new instance of the class. public RealTimeConfig() @@ -105,6 +105,14 @@ public RealTimeConfig(bool latestVersion) [ConfigItemCheckBox] public bool SwitchOffLightsAtNight { get; set; } + /// + /// Gets or sets the maximum height of a residential, commercial, or office building that will switch the lights off + /// at night. All buildings higher than this value will not switch the lights off. + /// + [ConfigItem("1General", "1Other", 4)] + [ConfigItemSlider(0, 100f, 5f, ValueType = SliderValueType.Default)] + public float SwitchOffLightsMaxHeight { get; set; } + /// Gets or sets a value indicating whether a citizen can abandon a journey when being too long in /// a traffic congestion or waiting too long for public transport. [ConfigItem("1General", "1Other", 5)] @@ -314,6 +322,8 @@ public void Validate() VirtualCitizens = (VirtualCitizensLevel)FastMath.Clamp((int)VirtualCitizens, (int)VirtualCitizensLevel.None, (int)VirtualCitizensLevel.Vanilla); ConstructionSpeed = FastMath.Clamp(ConstructionSpeed, 1u, 100u); + SwitchOffLightsMaxHeight = FastMath.Clamp(SwitchOffLightsMaxHeight, 0f, 100f); + SecondShiftQuota = FastMath.Clamp(SecondShiftQuota, 1u, 25u); NightShiftQuota = FastMath.Clamp(NightShiftQuota, 1u, 25u); LunchQuota = FastMath.Clamp(LunchQuota, 0u, 100u); @@ -363,6 +373,7 @@ public void ResetToDefaults() StopConstructionAtNight = true; ConstructionSpeed = 50; SwitchOffLightsAtNight = true; + SwitchOffLightsMaxHeight = 40f; CanAbandonJourney = true; SecondShiftQuota = 13; diff --git a/src/RealTime/Core/ModIds.cs b/src/RealTime/Core/ModIds.cs index fee4dd15..c0fd2497 100644 --- a/src/RealTime/Core/ModIds.cs +++ b/src/RealTime/Core/ModIds.cs @@ -26,5 +26,8 @@ internal static class ModIds /// The Workshop ID of the 'Force Level Up' mod. public const ulong ForceLevelUp = 523818382ul; + + /// The Workshop ID of the 'Realistic Walking Speed' mod. + public const ulong RealisticWalkingSpeed = 1412844620ul; } } diff --git a/src/RealTime/Core/RealTimeCore.cs b/src/RealTime/Core/RealTimeCore.cs index b34338e3..2b61eb76 100644 --- a/src/RealTime/Core/RealTimeCore.cs +++ b/src/RealTime/Core/RealTimeCore.cs @@ -141,9 +141,10 @@ public static RealTimeCore Run( new EventManagerConnection(), buildingManager, randomizer, - timeInfo); + timeInfo, + Constants.MaxTravelTime); - if (!SetupCustomAI(timeInfo, configProvider.Configuration, gameConnections, eventManager)) + if (!SetupCustomAI(timeInfo, configProvider.Configuration, gameConnections, eventManager, compatibility)) { Log.Error("The 'Real Time' mod failed to setup the customized AI and will now be deactivated."); patcher.Revert(); @@ -184,6 +185,11 @@ public static RealTimeCore Run( SimulationHandler.Buildings = BuildingAIPatches.RealTimeAI; SimulationHandler.Buildings.UpdateFrameDuration(); + if (appliedPatches.Contains(CitizenManagerPatch.CreateCitizenPatch1)) + { + CitizenManagerPatch.NewCitizenBehavior = new NewCitizenBehavior(randomizer, configProvider.Configuration); + } + if (appliedPatches.Contains(BuildingAIPatches.GetColor)) { SimulationHandler.Buildings.InitializeLightState(); @@ -191,11 +197,11 @@ public static RealTimeCore Run( SimulationHandler.Statistics = statistics; - if (appliedPatches.Contains(WorldInfoPanelPatches.UpdateBindings)) + if (appliedPatches.Contains(WorldInfoPanelPatch.UpdateBindings)) { - WorldInfoPanelPatches.CitizenInfoPanel = CustomCitizenInfoPanel.Enable(ResidentAIPatch.RealTimeAI, localizationProvider); - WorldInfoPanelPatches.VehicleInfoPanel = CustomVehicleInfoPanel.Enable(ResidentAIPatch.RealTimeAI, localizationProvider); - WorldInfoPanelPatches.CampusWorldInfoPanel = CustomCampusWorldInfoPanel.Enable(localizationProvider); + WorldInfoPanelPatch.CitizenInfoPanel = CustomCitizenInfoPanel.Enable(ResidentAIPatch.RealTimeAI, localizationProvider); + WorldInfoPanelPatch.VehicleInfoPanel = CustomVehicleInfoPanel.Enable(ResidentAIPatch.RealTimeAI, localizationProvider); + WorldInfoPanelPatch.CampusWorldInfoPanel = CustomCampusWorldInfoPanel.Enable(localizationProvider); } AwakeSleepSimulation.Install(configProvider.Configuration); @@ -243,6 +249,7 @@ public void Stop() SimulationHandler.Statistics = null; ParkPatches.SpareTimeBehavior = null; OutsideConnectionAIPatch.SpareTimeBehavior = null; + CitizenManagerPatch.NewCitizenBehavior = null; Log.Info("The 'Real Time' mod reverts method patches."); patcher.Revert(); @@ -261,14 +268,14 @@ public void Stop() StorageBase.CurrentLevelStorage.GameSaving -= GameSaving; - WorldInfoPanelPatches.CitizenInfoPanel?.Disable(); - WorldInfoPanelPatches.CitizenInfoPanel = null; + WorldInfoPanelPatch.CitizenInfoPanel?.Disable(); + WorldInfoPanelPatch.CitizenInfoPanel = null; - WorldInfoPanelPatches.VehicleInfoPanel?.Disable(); - WorldInfoPanelPatches.VehicleInfoPanel = null; + WorldInfoPanelPatch.VehicleInfoPanel?.Disable(); + WorldInfoPanelPatch.VehicleInfoPanel = null; - WorldInfoPanelPatches.CampusWorldInfoPanel?.Disable(); - WorldInfoPanelPatches.CampusWorldInfoPanel = null; + WorldInfoPanelPatch.CampusWorldInfoPanel?.Disable(); + WorldInfoPanelPatch.CampusWorldInfoPanel = null; isEnabled = false; } @@ -289,7 +296,7 @@ public void Translate(ILocalizationProvider localizationProvider) } timeBar.Translate(localizationProvider.CurrentCulture); - UIGraphPatches.Translate(localizationProvider.CurrentCulture); + UIGraphPatch.Translate(localizationProvider.CurrentCulture); } private static List GetMethodPatches(Compatibility compatibility) @@ -309,10 +316,10 @@ private static List GetMethodPatches(Compatibility compatibility) ResidentAIPatch.InstanceSimulationStep, TouristAIPatch.Location, TransferManagerPatch.AddOutgoingOffer, - WorldInfoPanelPatches.UpdateBindings, - UIGraphPatches.MinDataPoints, - UIGraphPatches.VisibleEndTime, - UIGraphPatches.BuildLabels, + WorldInfoPanelPatch.UpdateBindings, + UIGraphPatch.MinDataPoints, + UIGraphPatch.VisibleEndTime, + UIGraphPatch.BuildLabels, WeatherManagerPatch.SimulationStepImpl, ParkPatches.DistrictParkSimulation, OutsideConnectionAIPatch.DummyTrafficProbability, @@ -326,6 +333,8 @@ private static List GetMethodPatches(Compatibility compatibility) { patches.Add(ResidentAIPatch.UpdateAge); patches.Add(ResidentAIPatch.CanMakeBabies); + patches.Add(CitizenManagerPatch.CreateCitizenPatch1); + patches.Add(CitizenManagerPatch.CreateCitizenPatch2); } if (compatibility.IsAnyModActive( @@ -367,7 +376,8 @@ private static bool SetupCustomAI( TimeInfo timeInfo, RealTimeConfig config, GameConnections gameConnections, - RealTimeEventManager eventManager) + RealTimeEventManager eventManager, + Compatibility compatibility) { ResidentAIConnection residentAIConnection = ResidentAIPatch.GetResidentAIConnection(); if (residentAIConnection == null) @@ -375,8 +385,12 @@ private static bool SetupCustomAI( return false; } + float travelDistancePerCycle = compatibility.IsAnyModActive(ModIds.RealisticWalkingSpeed) + ? Constants.AverageTravelDistancePerCycle * 0.583f + : Constants.AverageTravelDistancePerCycle; + var spareTimeBehavior = new SpareTimeBehavior(config, timeInfo); - var travelBehavior = new TravelBehavior(gameConnections.BuildingManager); + var travelBehavior = new TravelBehavior(gameConnections.BuildingManager, travelDistancePerCycle); var workBehavior = new WorkBehavior(config, gameConnections.Random, gameConnections.BuildingManager, timeInfo, travelBehavior); ParkPatches.SpareTimeBehavior = spareTimeBehavior; @@ -437,7 +451,7 @@ private static void LoadStorageData(IEnumerable storageData, Stora } } - private void CityEventsChanged(object sender, EventArgs e) => timeBar.UpdateEventsDisplay(eventManager.CityEvents); + private void CityEventsChanged(object sender, EventArgs e) => timeBar.UpdateEventsDisplay(eventManager.AllEvents); private void GameSaving(object sender, EventArgs e) { diff --git a/src/RealTime/Core/RealTimeMod.cs b/src/RealTime/Core/RealTimeMod.cs index db5bd0f1..a9a683fb 100644 --- a/src/RealTime/Core/RealTimeMod.cs +++ b/src/RealTime/Core/RealTimeMod.cs @@ -59,7 +59,7 @@ public void OnEnabled() } Log.Info("The 'Real Time' mod has been enabled, version: " + modVersion); - configProvider = new ConfigurationProvider(RealTimeConfig.StorageId, Name, () => new RealTimeConfig(true)); + configProvider = new ConfigurationProvider(RealTimeConfig.StorageId, Name, () => new RealTimeConfig(latestVersion: true)); configProvider.LoadDefaultConfiguration(); localizationProvider = new LocalizationProvider(Name, modPath); } diff --git a/src/RealTime/CustomAI/Constants.cs b/src/RealTime/CustomAI/Constants.cs index 0ed67ad2..b8cf04c5 100644 --- a/src/RealTime/CustomAI/Constants.cs +++ b/src/RealTime/CustomAI/Constants.cs @@ -48,7 +48,7 @@ internal static class Constants public const float PrepareToWorkHours = 1f; /// An assumed maximum travel time to a target building (in hours). - public const float MaxTravelTime = 3.5f; + public const float MaxTravelTime = 4f; /// An assumed minimum travel time to a target building (in hours). public const float MinTravelTime = 0.5f; @@ -74,10 +74,9 @@ internal static class Constants /// The chance of a young female to get pregnant. public const uint YoungFemalePregnancyChance = 50u; - /// The average distance a citizen can move for (walking, by car, by public transport) during a full simulation - /// cycle at maximum time speed (6). + /// The average distance a citizen can travel for (walking, by car, by public transport) during a single simulation cycle. /// This value was determined empirically. - public const float AverageDistancePerSimulationCycle = 600f; + public const float AverageTravelDistancePerCycle = 450f; /// The maximum number of buildings (of one zone type) that are in construction or upgrading process. public const int MaximumBuildingsInConstruction = 50; diff --git a/src/RealTime/CustomAI/INewCitizenBehavior.cs b/src/RealTime/CustomAI/INewCitizenBehavior.cs new file mode 100644 index 00000000..ac12a414 --- /dev/null +++ b/src/RealTime/CustomAI/INewCitizenBehavior.cs @@ -0,0 +1,29 @@ +// +// Copyright (c) dymanoid. All rights reserved. +// + +namespace RealTime.CustomAI +{ + /// + /// An interface for a behavior that determines the creation of new citizens. + /// + internal interface INewCitizenBehavior + { + /// + /// Gets the education level of the new citizen based on their . + /// + /// + /// The citizen's age as raw value (0-255). + /// The current value of the citizen's education. + /// + /// The education level of the new citizen with the specified age. + Citizen.Education GetEducation(int age, Citizen.Education currentEducation); + + /// + /// Adjusts the age of the new citizen based on their current . + /// + /// The citizen's age as raw value (0-255). + /// An adjusted raw value (0-255) for the citizen's age. + int AdjustCitizenAge(int age); + } +} diff --git a/src/RealTime/CustomAI/NewCitizenBehavior.cs b/src/RealTime/CustomAI/NewCitizenBehavior.cs new file mode 100644 index 00000000..d0979252 --- /dev/null +++ b/src/RealTime/CustomAI/NewCitizenBehavior.cs @@ -0,0 +1,148 @@ +// +// Copyright (c) dymanoid. All rights reserved. +// + +namespace RealTime.CustomAI +{ + using System; + using RealTime.Config; + using RealTime.Simulation; + + /// + /// A behavior that determines the creation of new citizens. + /// + internal sealed class NewCitizenBehavior : INewCitizenBehavior + { + private readonly IRandomizer randomizer; + private readonly RealTimeConfig config; + + /// + /// Initializes a new instance of the class. + /// + /// The randomizer to use for random decisions. + /// The configuration to run with. + /// Thrown when any argument is null. + public NewCitizenBehavior(IRandomizer randomizer, RealTimeConfig config) + { + this.randomizer = randomizer ?? throw new ArgumentNullException(nameof(randomizer)); + this.config = config ?? throw new ArgumentNullException(nameof(config)); + } + + /// + /// Gets the education level of the new citizen based on their . + /// + /// The citizen's age as raw value (0-255). + /// The current value of the citizen's education. + /// The education level of the new citizen with the specified age. + public Citizen.Education GetEducation(int age, Citizen.Education currentEducation) + { + if (!config.UseSlowAging) + { + return currentEducation; + } + + var randomValue = randomizer.GetRandomValue(100u); + + // Age: + // 0-14 -> child + // 15-44 -> teen + // 45-89 -> young + // 90-179 -> adult + // 180-255 -> senior + if (age < 10) + { + // little children + return Citizen.Education.Uneducated; + } + else if (age < 40) + { + // children and most of the teens + return randomValue <= 25 ? Citizen.Education.Uneducated : Citizen.Education.OneSchool; + } + else if (age < 80) + { + // few teens and most of the young adults + if (randomValue < 10) + { + return Citizen.Education.Uneducated; + } + else if (randomValue < 50) + { + return Citizen.Education.OneSchool; + } + else + { + return Citizen.Education.TwoSchools; + } + } + else if (age < 120) + { + // few young adults and some adults + if (randomValue < 5) + { + return Citizen.Education.Uneducated; + } + else if (randomValue < 15) + { + return Citizen.Education.OneSchool; + } + else if (randomValue < 50) + { + return Citizen.Education.TwoSchools; + } + else + { + return Citizen.Education.ThreeSchools; + } + } + else + { + // mature adults and all seniors + if (randomValue < 10) + { + return Citizen.Education.Uneducated; + } + else if (randomValue < 20) + { + return Citizen.Education.OneSchool; + } + else if (randomValue < 40) + { + return Citizen.Education.TwoSchools; + } + else + { + return Citizen.Education.ThreeSchools; + } + } + } + + /// + /// Adjusts the age of the new citizen based on their current . + /// + /// The citizen's age as raw value (0-255). + /// An adjusted raw value (0-255) for the citizen's age. + public int AdjustCitizenAge(int age) + { + // Age: + // 0-14 -> child + // 15-44 -> teen + // 45-89 -> young + // 90-179 -> adult + // 180-255 -> senior + if (!config.UseSlowAging || age <= 1) + { + return age; + } + else if (age <= 15) + { + // children may be teens too + return 4 + randomizer.GetRandomValue(40u); + } + else + { + return 75 + randomizer.GetRandomValue(115u); + } + } + } +} diff --git a/src/RealTime/CustomAI/RealTimeBuildingAI.cs b/src/RealTime/CustomAI/RealTimeBuildingAI.cs index bf4af5c6..da031f53 100644 --- a/src/RealTime/CustomAI/RealTimeBuildingAI.cs +++ b/src/RealTime/CustomAI/RealTimeBuildingAI.cs @@ -509,18 +509,25 @@ private bool ShouldSwitchBuildingLightsOff(ushort buildingId, ItemClass.Service return false; case ItemClass.Service.Residential: + if (buildingManager.GetBuildingHeight(buildingId) > config.SwitchOffLightsMaxHeight) + { + return false; + } + float currentHour = timeInfo.CurrentHour; return currentHour < Math.Min(config.WakeUpHour, EarliestWakeUp) || currentHour >= config.GoToSleepHour; - case ItemClass.Service.Office when buildingManager.GetBuildingLevel(buildingId) != ItemClass.Level.Level1: - return false; - case ItemClass.Service.Commercial when subService == ItemClass.SubService.CommercialLeisure: return IsNoiseRestricted(buildingId); - case ItemClass.Service.Commercial - when subService == ItemClass.SubService.CommercialHigh && buildingManager.GetBuildingLevel(buildingId) != ItemClass.Level.Level1: - return false; + case ItemClass.Service.Office: + case ItemClass.Service.Commercial: + if (buildingManager.GetBuildingHeight(buildingId) > config.SwitchOffLightsMaxHeight) + { + return false; + } + + goto default; case ItemClass.Service.Monument: case ItemClass.Service.VarsitySports: diff --git a/src/RealTime/CustomAI/RealTimeHumanAIBase.cs b/src/RealTime/CustomAI/RealTimeHumanAIBase.cs index 0483f98b..0cf7b855 100644 --- a/src/RealTime/CustomAI/RealTimeHumanAIBase.cs +++ b/src/RealTime/CustomAI/RealTimeHumanAIBase.cs @@ -10,7 +10,6 @@ namespace RealTime.CustomAI using RealTime.GameConnection; using RealTime.Simulation; using SkyTools.Tools; - using static Constants; /// /// A base class for the custom logic of a human in the game. @@ -141,7 +140,7 @@ protected bool EnsureCitizenCanBeProcessed(uint citizenId, ref TCitizen citizen) /// The citizen data reference. /// /// The city event or null if none found. - protected ICityEvent GetUpcomingEventToAttend(uint citizenId, ref TCitizen citizen) + protected ICityEvent GetEventToAttend(uint citizenId, ref TCitizen citizen) { ushort currentBuilding = CitizenProxy.GetCurrentBuilding(ref citizen); if (EventMgr.GetEventState(currentBuilding, DateTime.MaxValue) == CityEventState.Ongoing) @@ -149,13 +148,14 @@ protected ICityEvent GetUpcomingEventToAttend(uint citizenId, ref TCitizen citiz return null; } - DateTime earliestStart = TimeInfo.Now.AddHours(MinTravelTime); - DateTime latestStart = TimeInfo.Now.AddHours(MaxTravelTime); - - ICityEvent upcomingEvent = EventMgr.GetUpcomingCityEvent(earliestStart, latestStart); - if (upcomingEvent != null && CanAttendEvent(citizenId, ref citizen, upcomingEvent)) + var cityEvents = EventMgr.EventsToAttend; + for (int i = 0; i < cityEvents.Count; ++i) { - return upcomingEvent; + var cityEvent = cityEvents[i]; + if (CanAttendEvent(citizenId, ref citizen, cityEvent)) + { + return cityEvent; + } } return null; diff --git a/src/RealTime/CustomAI/RealTimeResidentAI.Visit.cs b/src/RealTime/CustomAI/RealTimeResidentAI.Visit.cs index 12d56f82..c0a77921 100644 --- a/src/RealTime/CustomAI/RealTimeResidentAI.Visit.cs +++ b/src/RealTime/CustomAI/RealTimeResidentAI.Visit.cs @@ -24,7 +24,7 @@ private bool ScheduleRelaxing(ref CitizenSchedule schedule, uint citizenId, ref return false; } - ICityEvent cityEvent = GetUpcomingEventToAttend(citizenId, ref citizen); + ICityEvent cityEvent = GetEventToAttend(citizenId, ref citizen); if (cityEvent != null) { ushort currentBuilding = CitizenProxy.GetCurrentBuilding(ref citizen); diff --git a/src/RealTime/CustomAI/RealTimeTouristAI.cs b/src/RealTime/CustomAI/RealTimeTouristAI.cs index 035546ee..333e5ed9 100644 --- a/src/RealTime/CustomAI/RealTimeTouristAI.cs +++ b/src/RealTime/CustomAI/RealTimeTouristAI.cs @@ -240,7 +240,7 @@ when BuildingMgr.GetBuildingSubService(visitBuilding) == ItemClass.SubService.Co if (Random.ShouldOccur(TouristEventChance) && !WeatherInfo.IsBadWeather) { - ICityEvent cityEvent = GetUpcomingEventToAttend(citizenId, ref citizen); + ICityEvent cityEvent = GetEventToAttend(citizenId, ref citizen); if (cityEvent != null && StartMovingToVisitBuilding(instance, citizenId, ref citizen, CitizenProxy.GetCurrentBuilding(ref citizen), cityEvent.BuildingId)) { diff --git a/src/RealTime/CustomAI/TravelBehavior.cs b/src/RealTime/CustomAI/TravelBehavior.cs index 1e9a6fff..6cf35613 100644 --- a/src/RealTime/CustomAI/TravelBehavior.cs +++ b/src/RealTime/CustomAI/TravelBehavior.cs @@ -4,6 +4,7 @@ namespace RealTime.CustomAI { + using System; using RealTime.GameConnection; using SkyTools.Tools; using static Constants; @@ -14,17 +15,26 @@ namespace RealTime.CustomAI internal sealed class TravelBehavior : ITravelBehavior { private readonly IBuildingManagerConnection buildingManager; - private float averageCitizenSpeed; + private readonly float travelDistancePerCycle; + private float averageTravelSpeedPerHour; /// Initializes a new instance of the class. /// /// A proxy object that provides a way to call the game-specific methods of the class. /// - /// Thrown when the argument is null. - public TravelBehavior(IBuildingManagerConnection buildingManager) + /// The average distance a citizen can travel during a single simulation cycle. + /// Thrown when is null. + /// Thrown when is negative or zero. + public TravelBehavior(IBuildingManagerConnection buildingManager, float travelDistancePerCycle) { - this.buildingManager = buildingManager ?? throw new System.ArgumentNullException(nameof(buildingManager)); - averageCitizenSpeed = AverageDistancePerSimulationCycle; + if (travelDistancePerCycle <= 0) + { + throw new ArgumentException("The travel distance per cycle cannot be negative or zero."); + } + + this.buildingManager = buildingManager ?? throw new ArgumentNullException(nameof(buildingManager)); + this.travelDistancePerCycle = travelDistancePerCycle; + averageTravelSpeedPerHour = travelDistancePerCycle; } /// Sets the duration (in hours) of a full simulation cycle for all citizens. @@ -37,7 +47,7 @@ public void SetSimulationCyclePeriod(float cyclePeriod) cyclePeriod = 1f; } - averageCitizenSpeed = AverageDistancePerSimulationCycle / cyclePeriod; + averageTravelSpeedPerHour = travelDistancePerCycle / cyclePeriod; } /// Gets an estimated travel time (in hours) between two specified buildings. @@ -57,7 +67,7 @@ public float GetEstimatedTravelTime(ushort building1, ushort building2) return MinTravelTime; } - return FastMath.Clamp(distance / averageCitizenSpeed, MinTravelTime, MaxTravelTime); + return FastMath.Clamp(distance / averageTravelSpeedPerHour, MinTravelTime, MaxTravelTime); } } } diff --git a/src/RealTime/Events/CityEventBase.cs b/src/RealTime/Events/CityEventBase.cs index a9cfa3fe..3045c069 100644 --- a/src/RealTime/Events/CityEventBase.cs +++ b/src/RealTime/Events/CityEventBase.cs @@ -23,6 +23,11 @@ internal abstract class CityEventBase : ICityEvent /// Gets the localized name of the building this city event takes place in. public string BuildingName { get; private set; } + /// + /// Gets the event color. + /// + public abstract EventColor Color { get; } + /// Accepts an event attendee with specified properties. /// The attendee age. /// The attendee gender. diff --git a/src/RealTime/Events/EventColor.cs b/src/RealTime/Events/EventColor.cs new file mode 100644 index 00000000..e53b2d85 --- /dev/null +++ b/src/RealTime/Events/EventColor.cs @@ -0,0 +1,68 @@ +// +// Copyright (c) dymanoid. All rights reserved. +// + +namespace RealTime.Events +{ + using System; + + /// + /// A struct representing the event color. + /// + internal struct EventColor : IEquatable + { + /// + /// Initializes a new instance of the struct. + /// + /// The red component of the color. + /// The green component of the color. + /// The blue component of the color. + public EventColor(byte red, byte green, byte blue) + { + Red = red; + Green = green; + Blue = blue; + } + + /// + /// Gets the red component of the color. + /// + public byte Red { get; } + + /// + /// Gets the green component of the color. + /// + public byte Green { get; } + + /// + /// Gets the blue component of the color. + /// + public byte Blue { get; } + + public static bool operator ==(EventColor left, EventColor right) + { + return left.Equals(right); + } + + public static bool operator !=(EventColor left, EventColor right) + { + return !(left == right); + } + + /// + public override bool Equals(object obj) => obj is EventColor color && Equals(color); + + /// + public bool Equals(EventColor other) => Red == other.Red && Green == other.Green && Blue == other.Blue; + + /// + public override int GetHashCode() + { + var hashCode = -1058441243; + hashCode = hashCode * -1521134295 + Red.GetHashCode(); + hashCode = hashCode * -1521134295 + Green.GetHashCode(); + hashCode = hashCode * -1521134295 + Blue.GetHashCode(); + return hashCode; + } + } +} diff --git a/src/RealTime/Events/ICityEvent.cs b/src/RealTime/Events/ICityEvent.cs index 09537b55..cdb8d265 100644 --- a/src/RealTime/Events/ICityEvent.cs +++ b/src/RealTime/Events/ICityEvent.cs @@ -22,6 +22,11 @@ internal interface ICityEvent /// Gets the localized name of the building this city event takes place in. string BuildingName { get; } + /// + /// Gets the event color. + /// + EventColor Color { get; } + /// /// Configures this event to take place in the specified building and at the specified start time. /// diff --git a/src/RealTime/Events/IRealTimeEventManager.cs b/src/RealTime/Events/IRealTimeEventManager.cs index 21506a8c..50125b29 100644 --- a/src/RealTime/Events/IRealTimeEventManager.cs +++ b/src/RealTime/Events/IRealTimeEventManager.cs @@ -9,6 +9,12 @@ namespace RealTime.Events /// An interface for the customized city events manager. internal interface IRealTimeEventManager { + /// + /// Gets the events that can be attended by the citizens when they start traveling to the event + /// at the current game time. + /// + IReadOnlyList EventsToAttend { get; } + /// /// Gets the instance of an ongoing or upcoming city event that takes place in a building /// with specified ID. @@ -17,14 +23,6 @@ internal interface IRealTimeEventManager /// An instance of the first matching city event, or null if none found. ICityEvent GetCityEvent(ushort buildingId); - /// - /// Gets the instance of an upcoming city event whose start time is between the specified values. - /// - /// The earliest city event start time to consider. - /// The latest city event start time to consider. - /// An instance of the first matching city event, or null if none found. - ICityEvent GetUpcomingCityEvent(DateTime earliestStartTime, DateTime latestStartTime); - /// Gets the state of a city event in the specified building. /// The building ID to check events in. /// The latest start time of events to consider. diff --git a/src/RealTime/Events/RealTimeCityEvent.cs b/src/RealTime/Events/RealTimeCityEvent.cs index 97bb6dc1..62194fee 100644 --- a/src/RealTime/Events/RealTimeCityEvent.cs +++ b/src/RealTime/Events/RealTimeCityEvent.cs @@ -36,6 +36,11 @@ public RealTimeCityEvent(CityEventTemplate eventTemplate, int attendeesCount) this.attendeesCount = attendeesCount; } + /// + /// Gets the event color. + /// + public override EventColor Color { get; } = new EventColor(180, 0, 90); + /// Accepts an event attendee with specified properties. /// The attendee age. /// The attendee gender. diff --git a/src/RealTime/Events/RealTimeEventManager.cs b/src/RealTime/Events/RealTimeEventManager.cs index 606e6f3c..7b4e2262 100644 --- a/src/RealTime/Events/RealTimeEventManager.cs +++ b/src/RealTime/Events/RealTimeEventManager.cs @@ -5,6 +5,7 @@ namespace RealTime.Events using System; using System.Collections.Generic; using System.IO; + using System.Linq; using System.Xml.Serialization; using RealTime.Config; using RealTime.Events.Storage; @@ -34,11 +35,14 @@ internal sealed class RealTimeEventManager : IStorageData, IRealTimeEventManager private readonly IBuildingManagerConnection buildingManager; private readonly IRandomizer randomizer; private readonly ITimeInfo timeInfo; - private readonly List currentEvents; - private readonly IReadOnlyList readonlyCurrentEvents; + private readonly List eventsCache; + private readonly IReadOnlyList readonlyEventsCache; - private ICityEvent lastActiveEvent; - private ICityEvent activeEvent; + private readonly float attendingTimeMargin; + private readonly List eventsToAttend; + + private readonly List finishedEvents = new List(); + private readonly List activeEvents = new List(); private DateTime lastProcessed; private DateTime earliestEvent; @@ -55,6 +59,8 @@ internal sealed class RealTimeEventManager : IStorageData, IRealTimeEventManager /// An object that implements of the interface. /// /// The time information source. + /// The time margin in hours specifying the maximum time before an event + /// can be attended by the citizen. /// Thrown when any argument is null. public RealTimeEventManager( RealTimeConfig config, @@ -62,7 +68,8 @@ public RealTimeEventManager( IEventManagerConnection eventManager, IBuildingManagerConnection buildingManager, IRandomizer randomizer, - ITimeInfo timeInfo) + ITimeInfo timeInfo, + float attendingTimeMargin) { this.config = config ?? throw new ArgumentNullException(nameof(config)); this.eventProvider = eventProvider ?? throw new ArgumentNullException(nameof(eventProvider)); @@ -70,36 +77,37 @@ public RealTimeEventManager( this.buildingManager = buildingManager ?? throw new ArgumentNullException(nameof(buildingManager)); this.randomizer = randomizer ?? throw new ArgumentNullException(nameof(randomizer)); this.timeInfo = timeInfo ?? throw new ArgumentNullException(nameof(timeInfo)); + this.attendingTimeMargin = attendingTimeMargin; + upcomingEvents = new LinkedList(); - currentEvents = new List(); - readonlyCurrentEvents = new ReadOnlyList(currentEvents); + eventsCache = new List(); + readonlyEventsCache = new ReadOnlyList(eventsCache); + eventsToAttend = new List(); + EventsToAttend = new ReadOnlyList(eventsToAttend); } /// Occurs when currently preparing, ready, ongoing, or recently finished events change. public event EventHandler EventsChanged; /// Gets the currently preparing, ready, ongoing, or recently finished city events. - public IReadOnlyList CityEvents + public IReadOnlyList AllEvents { get { - currentEvents.Clear(); - - if (lastActiveEvent != null) - { - currentEvents.Add(lastActiveEvent); - } - - if (activeEvent != null) - { - currentEvents.Add(activeEvent); - } - - upcomingEvents.CopyTo(currentEvents); - return readonlyCurrentEvents; + eventsCache.Clear(); + eventsCache.AddRange(finishedEvents); + eventsCache.AddRange(activeEvents); + upcomingEvents.CopyTo(eventsCache); + return readonlyEventsCache; } } + /// + /// Gets the events that can be attended by the citizens when they start traveling to the event + /// at the current game time. + /// + public IReadOnlyList EventsToAttend { get; } + /// Gets an unique ID of this storage data set. string IStorageData.StorageDataId => StorageDataId; @@ -122,7 +130,7 @@ public CityEventState GetEventState(ushort buildingId, DateTime latestStart) EventData.Flags vanillaEventState = eventManager.GetEventFlags(eventId); if ((vanillaEventState & (EventData.Flags.Preparing | EventData.Flags.Ready)) != 0) { - if (eventManager.TryGetEventInfo(eventId, out _, out DateTime startTime, out _, out _) && startTime <= latestStart) + if (eventManager.TryGetEventStartTime(eventId, out DateTime startTime) && startTime <= latestStart) { return CityEventState.Upcoming; } @@ -141,12 +149,12 @@ public CityEventState GetEventState(ushort buildingId, DateTime latestStart) } } - if (activeEvent != null && activeEvent.BuildingId == buildingId) + if (FindEventByBuildingId(activeEvents, buildingId) != null) { return CityEventState.Ongoing; } - if (lastActiveEvent != null && lastActiveEvent.BuildingId == buildingId) + if (FindEventByBuildingId(finishedEvents, buildingId) != null) { return CityEventState.Finished; } @@ -159,30 +167,6 @@ public CityEventState GetEventState(ushort buildingId, DateTime latestStart) return CityEventState.None; } - /// - /// Gets the instance of an upcoming city event whose start time is between the specified values. - /// - /// The earliest city event start time to consider. - /// The latest city event start time to consider. - /// An instance of the first matching city event, or null if none found. - public ICityEvent GetUpcomingCityEvent(DateTime earliestStartTime, DateTime latestStartTime) - { - if (activeEvent != null && activeEvent.EndTime > latestStartTime) - { - return activeEvent; - } - - if (upcomingEvents.Count == 0) - { - return null; - } - - ICityEvent upcomingEvent = upcomingEvents.First.Value; - return upcomingEvent.StartTime >= earliestStartTime && upcomingEvent.StartTime <= latestStartTime - ? upcomingEvent - : null; - } - /// /// Gets the instance of an ongoing or upcoming city event that takes place in a building /// with specified ID. @@ -196,7 +180,8 @@ public ICityEvent GetCityEvent(ushort buildingId) return null; } - if (activeEvent != null && activeEvent.BuildingId == buildingId) + var activeEvent = FindEventByBuildingId(activeEvents, buildingId); + if (activeEvent != null) { return activeEvent; } @@ -231,6 +216,8 @@ public void ProcessEvents() OnEventsChanged(); } + UpdateEventsToAttend(); + if ((timeInfo.Now - lastProcessed) < EventProcessInterval) { return; @@ -257,8 +244,8 @@ public void ProcessEvents() /// A to read the data set from. void IStorageData.ReadData(Stream source) { - lastActiveEvent = null; - activeEvent = null; + finishedEvents.Clear(); + activeEvents.Clear(); upcomingEvents.Clear(); var serializer = new XmlSerializer(typeof(RealTimeEventStorageContainer)); @@ -279,7 +266,7 @@ void IStorageData.ReadData(Stream source) if (realTimeEvent.EndTime < timeInfo.Now) { - lastActiveEvent = realTimeEvent; + finishedEvents.Add(realTimeEvent); } else { @@ -300,22 +287,33 @@ void IStorageData.StoreData(Stream target) EarliestEvent = earliestEvent.Ticks, }; - AddEventToStorage(lastActiveEvent); - AddEventToStorage(activeEvent); - foreach (var cityEvent in upcomingEvents) - { - AddEventToStorage(cityEvent); - } + AddEventsToStorage(finishedEvents); + AddEventsToStorage(activeEvents); + AddEventsToStorage(upcomingEvents); serializer.Serialize(target, data); - void AddEventToStorage(ICityEvent cityEvent) + void AddEventsToStorage(IEnumerable cityEvents) + { + foreach (var cityEvent in cityEvents.OfType()) + { + data.Events.Add(cityEvent.GetStorageData()); + } + } + } + + private static ICityEvent FindEventByBuildingId(List cityEvents, ushort buildingId) + { + for (int i = 0; i < cityEvents.Count; ++i) { - if (cityEvent != null && cityEvent is RealTimeCityEvent realTimeEvent) + var cityEvent = cityEvents[i]; + if (cityEvent.BuildingId == buildingId) { - data.Events.Add(realTimeEvent.GetStorageData()); + return cityEvent; } } + + return null; } private static ICityEvent GetVanillaEvent(IReadOnlyList events, ushort eventId, ushort buildingId) @@ -335,11 +333,15 @@ private static ICityEvent GetVanillaEvent(IReadOnlyList events, usho private void Update() { - if (activeEvent != null && activeEvent.EndTime <= timeInfo.Now) + for (int i = activeEvents.Count - 1; i >= 0; --i) { - Log.Debug(LogCategory.Events, timeInfo.Now, $"Event finished in {activeEvent.BuildingId}, started at {activeEvent.StartTime}, end time {activeEvent.EndTime}"); - lastActiveEvent = activeEvent; - activeEvent = null; + var cityEvent = activeEvents[i]; + if (cityEvent.EndTime <= timeInfo.Now) + { + Log.Debug(LogCategory.Events, timeInfo.Now, $"Event finished in {cityEvent.BuildingId}, started at {cityEvent.StartTime}, end time {cityEvent.EndTime}"); + finishedEvents.Add(cityEvent); + activeEvents.RemoveAt(i); + } } bool eventsChanged = SynchronizeWithVanillaEvents(); @@ -349,16 +351,11 @@ private void Update() LinkedListNode upcomingEvent = upcomingEvents.First; while (upcomingEvent != null && upcomingEvent.Value.StartTime <= timeInfo.Now) { - if (activeEvent != null) - { - lastActiveEvent = activeEvent; - } - - activeEvent = upcomingEvent.Value; + activeEvents.Add(upcomingEvent.Value); upcomingEvents.RemoveFirst(); + Log.Debug(LogCategory.Events, timeInfo.Now, $"Event started! Building {upcomingEvent.Value.BuildingId}, ends on {upcomingEvent.Value.EndTime}"); eventsChanged = true; upcomingEvent = upcomingEvent.Next; - Log.Debug(LogCategory.Events, timeInfo.Now, $"Event started! Building {activeEvent.BuildingId}, ends on {activeEvent.EndTime}"); } } @@ -368,90 +365,129 @@ private void Update() } } + private void UpdateEventsToAttend() + { + DateTime latestAttendTime = timeInfo.Now.AddHours(attendingTimeMargin); + + eventsToAttend.Clear(); + for (int i = 0; i < activeEvents.Count; ++i) + { + var activeEvent = activeEvents[i]; + if (activeEvent.EndTime > latestAttendTime) + { + eventsToAttend.Add(activeEvent); + } + } + + if (upcomingEvents.Count == 0) + { + return; + } + + var upcomingEventNode = upcomingEvents.First; + while (upcomingEventNode != null) + { + var upcomingEvent = upcomingEventNode.Value; + if (upcomingEvent.StartTime <= latestAttendTime) + { + eventsToAttend.Add(upcomingEvent); + } + + upcomingEventNode = upcomingEventNode.Next; + } + } + private bool SynchronizeWithVanillaEvents() { - bool result = false; + bool eventsChanged = false; DateTime today = timeInfo.Now.Date; var upcomingEventIds = eventManager.GetUpcomingEvents(today, today.AddDays(1)); for (int i = 0; i < upcomingEventIds.Count; ++i) { - ushort eventId = upcomingEventIds[i]; + // The evaluation order is important here - avoid short-circuit, we need to call the method on each iteration + eventsChanged = SynchronizeWithVanillaEvent(upcomingEventIds[i]) || eventsChanged; + } - if (!eventManager.TryGetEventInfo(eventId, out ushort buildingId, out DateTime startTime, out float duration, out float ticketPrice)) - { - continue; - } + return eventsChanged; + } - if (startTime.AddHours(duration) < timeInfo.Now) - { - continue; - } + private bool SynchronizeWithVanillaEvent(ushort eventId) + { + if (!eventManager.TryGetEventInfo(eventId, out var eventInfo)) + { + return false; + } - var existingVanillaEvent = GetVanillaEvent(CityEvents, eventId, buildingId); + var startTime = eventInfo.StartTime; - if (existingVanillaEvent != null) - { - if (Math.Abs((startTime - existingVanillaEvent.StartTime).TotalMinutes) <= 5d) - { - continue; - } - else if (existingVanillaEvent == activeEvent) - { - activeEvent = null; - } - else - { - upcomingEvents.Remove(existingVanillaEvent); - } - } + if (startTime.AddHours(eventInfo.Duration) < timeInfo.Now) + { + return false; + } + + var existingVanillaEvent = GetVanillaEvent(AllEvents, eventId, eventInfo.BuildingId); - DateTime adjustedStartTime = AdjustEventStartTime(startTime, randomize: false); - if (adjustedStartTime != startTime) + if (existingVanillaEvent != null) + { + if (Math.Abs((startTime - existingVanillaEvent.StartTime).TotalMinutes) <= 5d) { - startTime = adjustedStartTime; - eventManager.SetStartTime(eventId, startTime); + return false; } - - var newEvent = new VanillaEvent(eventId, duration, ticketPrice); - newEvent.Configure(buildingId, buildingManager.GetBuildingName(buildingId), startTime); - result = true; - Log.Debug(LogCategory.Events, timeInfo.Now, $"Vanilla event registered for {newEvent.BuildingId}, start time {newEvent.StartTime}, end time {newEvent.EndTime}"); - - LinkedListNode existingEvent = upcomingEvents.FirstOrDefaultNode(e => e.StartTime >= startTime); - if (existingEvent == null) + else if (activeEvents.Contains(existingVanillaEvent)) { - upcomingEvents.AddLast(newEvent); + activeEvents.Remove(existingVanillaEvent); } else { - upcomingEvents.AddBefore(existingEvent, newEvent); - if (existingEvent.Value.StartTime < newEvent.EndTime && existingEvent.Value is RealTimeCityEvent) - { - // Avoid multiple events at the same time - vanilla events have priority - upcomingEvents.Remove(existingEvent); - earliestEvent = newEvent.EndTime.AddHours(12f); - } + upcomingEvents.Remove(existingVanillaEvent); } } - return result; + DateTime adjustedStartTime = AdjustEventStartTime(startTime, randomize: false); + if (adjustedStartTime != startTime) + { + startTime = adjustedStartTime; + eventManager.SetStartTime(eventId, startTime); + } + + var newEvent = new VanillaEvent(eventId, eventInfo.Duration, eventInfo.TicketPrice, eventManager); + newEvent.Configure(eventInfo.BuildingId, buildingManager.GetBuildingName(eventInfo.BuildingId), startTime); + Log.Debug(LogCategory.Events, timeInfo.Now, $"Vanilla event registered for {newEvent.BuildingId}, start time {newEvent.StartTime}, end time {newEvent.EndTime}"); + + LinkedListNode existingEvent = upcomingEvents.FirstOrDefaultNode(e => e.StartTime >= startTime); + if (existingEvent == null) + { + upcomingEvents.AddLast(newEvent); + } + else + { + upcomingEvents.AddBefore(existingEvent, newEvent); + } + + return true; } private bool RemoveCanceledEvents() { - if (lastActiveEvent != null && MustCancelEvent(lastActiveEvent)) + for (int i = finishedEvents.Count - 1; i >= 0; --i) { - lastActiveEvent = null; + if (MustCancelEvent(finishedEvents[i])) + { + finishedEvents.RemoveAt(i); + } } bool eventsChanged = false; - if (activeEvent != null && MustCancelEvent(activeEvent)) + for (int i = activeEvents.Count - 1; i >= 0; --i) { - Log.Debug(LogCategory.Events, $"The active event in building {activeEvent.BuildingId} must be canceled"); - activeEvent = null; - eventsChanged = true; + if (MustCancelEvent(activeEvents[i])) + { + Log.Debug(LogCategory.Events, $"The active event in building {activeEvents[i].BuildingId} must be canceled"); + activeEvents.RemoveAt(i); + eventsChanged = true; + } } if (upcomingEvents.Count == 0) diff --git a/src/RealTime/Events/VanillaEvent.cs b/src/RealTime/Events/VanillaEvent.cs index 37685aba..ae97577a 100644 --- a/src/RealTime/Events/VanillaEvent.cs +++ b/src/RealTime/Events/VanillaEvent.cs @@ -4,6 +4,7 @@ namespace RealTime.Events { + using RealTime.GameConnection; using RealTime.Simulation; /// A class for the default game city event. @@ -12,21 +13,29 @@ internal sealed class VanillaEvent : CityEventBase { private readonly float duration; private readonly float ticketPrice; + private readonly IEventManagerConnection eventManager; /// Initializes a new instance of the class. /// The event ID. /// The city event duration in hours. /// The event ticket price. - public VanillaEvent(ushort id, float duration, float ticketPrice) + /// An reference. + public VanillaEvent(ushort id, float duration, float ticketPrice, IEventManagerConnection eventManager) { this.duration = duration; this.ticketPrice = ticketPrice; EventId = id; + this.eventManager = eventManager ?? throw new System.ArgumentNullException(nameof(eventManager)); } /// Gets the vanilla event ID. public ushort EventId { get; } + /// + /// Gets the event color. + /// + public override EventColor Color => eventManager.GetEventColor(EventId); + /// Accepts an event attendee with specified properties. /// The attendee age. /// The attendee gender. diff --git a/src/RealTime/GameConnection/BuildingManagerConnection.cs b/src/RealTime/GameConnection/BuildingManagerConnection.cs index 491042c1..728217be 100644 --- a/src/RealTime/GameConnection/BuildingManagerConnection.cs +++ b/src/RealTime/GameConnection/BuildingManagerConnection.cs @@ -361,14 +361,14 @@ public void UpdateBuildingColors(ushort buildingId) } } - /// Gets the building's level. + /// Gets the building's height in game units. /// The ID of the building. - /// The level of the building with the specified ID. - public ItemClass.Level GetBuildingLevel(ushort buildingId) + /// The height of the building with the specified ID. + public float GetBuildingHeight(ushort buildingId) { return buildingId == 0 - ? ItemClass.Level.None - : BuildingManager.instance.m_buildings.m_buffer[buildingId].Info?.m_class?.m_level ?? ItemClass.Level.None; + ? 0f + : BuildingManager.instance.m_buildings.m_buffer[buildingId].Info?.m_size.y ?? 0f; } /// diff --git a/src/RealTime/GameConnection/EventManagerConnection.cs b/src/RealTime/GameConnection/EventManagerConnection.cs index 06145aa9..3fea4202 100644 --- a/src/RealTime/GameConnection/EventManagerConnection.cs +++ b/src/RealTime/GameConnection/EventManagerConnection.cs @@ -6,6 +6,7 @@ namespace RealTime.GameConnection { using System; using System.Collections.Generic; + using RealTime.Events; /// /// The default implementation of the interface. @@ -81,41 +82,75 @@ public IReadOnlyList GetUpcomingEvents(DateTime earliestTime, DateTime l return readonlyUpcomingEvents; } + /// Gets the start time of a city event with specified ID. + /// The ID of the city event to get start time of. + /// The start time of the event with the specified ID. + /// true if the start time was retrieved; otherwise, false. + public bool TryGetEventStartTime(ushort eventId, out DateTime startTime) + { + if (eventId == 0 || eventId >= EventManager.instance.m_events.m_size) + { + startTime = default; + return false; + } + + ref EventData eventData = ref EventManager.instance.m_events.m_buffer[eventId]; + if (eventData.Info?.m_type == EventManager.EventType.AcademicYear) + { + startTime = default; + return false; + } + + startTime = eventData.StartTime; + return true; + } + /// /// Gets various information about a city event with specified ID. /// /// The ID of the city event to get information for. - /// The ID of a building where the city event takes place. - /// The start time of the city event. - /// The duration in hours of the city event. - /// The city event's ticket price. + /// A ref-struct containing the event information. /// /// true if the information was retrieved; otherwise, false. /// - public bool TryGetEventInfo(ushort eventId, out ushort buildingId, out DateTime startTime, out float duration, out float ticketPrice) + public bool TryGetEventInfo(ushort eventId, out VanillaEventInfo eventInfo) { - buildingId = default; - duration = default; - startTime = default; - ticketPrice = default; if (eventId == 0 || eventId >= EventManager.instance.m_events.m_size) { + eventInfo = default; return false; } ref EventData eventData = ref EventManager.instance.m_events.m_buffer[eventId]; if (eventData.Info?.m_type == EventManager.EventType.AcademicYear) { + eventInfo = default; return false; } - buildingId = eventData.m_building; - startTime = eventData.StartTime; - duration = eventData.Info.m_eventAI.m_eventDuration; - ticketPrice = eventData.m_ticketPrice / 100f; + eventInfo = new VanillaEventInfo( + eventData.m_building, + eventData.StartTime, + eventData.Info.m_eventAI.m_eventDuration, + eventData.m_ticketPrice / 100f); return true; } + /// Gets the color of a city event with specified ID. + /// The ID of the city event to get the color of. + /// The color of the event. + public EventColor GetEventColor(ushort eventId) + { + if (eventId == 0 || eventId >= EventManager.instance.m_events.m_size) + { + return default; + } + + ref EventData eventData = ref EventManager.instance.m_events.m_buffer[eventId]; + var color = eventData.m_color; + return new EventColor(color.r, color.g, color.b); + } + /// Sets the start time of the event to the specified value. /// The ID of the event to change. /// The new event start time. diff --git a/src/RealTime/GameConnection/IBuildingManagerConnection.cs b/src/RealTime/GameConnection/IBuildingManagerConnection.cs index baa11dca..de78f754 100644 --- a/src/RealTime/GameConnection/IBuildingManagerConnection.cs +++ b/src/RealTime/GameConnection/IBuildingManagerConnection.cs @@ -158,10 +158,10 @@ ushort FindActiveBuilding( /// The ID of the building to update. void UpdateBuildingColors(ushort buildingId); - /// Gets the building's level. + /// Gets the building's height in game units. /// The ID of the building. - /// The level of the building with the specified ID. - ItemClass.Level GetBuildingLevel(ushort buildingId); + /// The height of the building with the specified ID. + float GetBuildingHeight(ushort buildingId); /// Visually deactivates the building with specified ID without affecting its production or coverage. /// The building ID. diff --git a/src/RealTime/GameConnection/IEventManagerConnection.cs b/src/RealTime/GameConnection/IEventManagerConnection.cs index 17be5c8e..0dd2b74f 100644 --- a/src/RealTime/GameConnection/IEventManagerConnection.cs +++ b/src/RealTime/GameConnection/IEventManagerConnection.cs @@ -5,6 +5,7 @@ namespace RealTime.GameConnection { using System; + using RealTime.Events; /// An interface for the game specific logic related to the event management. internal interface IEventManagerConnection @@ -24,12 +25,20 @@ internal interface IEventManagerConnection /// Gets various information about a city event with specified ID. /// The ID of the city event to get information for. - /// The ID of a building where the city event takes place. - /// The start time of the city event. - /// The duration in hours of the city event. - /// The city event's ticket price. + /// A ref-struct containing the event information. /// true if the information was retrieved; otherwise, false. - bool TryGetEventInfo(ushort eventId, out ushort buildingId, out DateTime startTime, out float duration, out float ticketPrice); + bool TryGetEventInfo(ushort eventId, out VanillaEventInfo eventInfo); + + /// Gets the start time of a city event with specified ID. + /// The ID of the city event to get start time of. + /// The start time of the event with the specified ID. + /// true if the start time was retrieved; otherwise, false. + bool TryGetEventStartTime(ushort eventId, out DateTime startTime); + + /// Gets the color of a city event with specified ID. + /// The ID of the city event to get the color of. + /// The color of the event. + EventColor GetEventColor(ushort eventId); /// Sets the start time of the event to the specified value. /// The ID of the event to change. diff --git a/src/RealTime/GameConnection/Patches/CitizenManagerPatch.cs b/src/RealTime/GameConnection/Patches/CitizenManagerPatch.cs new file mode 100644 index 00000000..93d87b17 --- /dev/null +++ b/src/RealTime/GameConnection/Patches/CitizenManagerPatch.cs @@ -0,0 +1,92 @@ +// +// Copyright (c) dymanoid. All rights reserved. +// + +namespace RealTime.GameConnection.Patches +{ + using System.Reflection; + using ColossalFramework.Math; + using RealTime.CustomAI; + using SkyTools.Patching; + + /// + /// A static class that provides the patch objects for the game's citizen manager. + /// + internal static class CitizenManagerPatch + { + /// + /// Gets or sets the implementation of the interface. + /// + public static INewCitizenBehavior NewCitizenBehavior { get; set; } + + /// Gets the patch object for the method that creates a new citizen. + public static IPatch CreateCitizenPatch1 { get; } = new CitizenManager_CreateCitizen1(); + + /// Gets the patch object for the method that creates a new citizen (with specified gender). + public static IPatch CreateCitizenPatch2 { get; } = new CitizenManager_CreateCitizen2(); + + private static void UpdateCizizenAge(uint citizenId) + { + ref var citizen = ref CitizenManager.instance.m_citizens.m_buffer[citizenId]; + citizen.Age = NewCitizenBehavior.AdjustCitizenAge(citizen.Age); + } + + private static void UpdateCitizenEducation(uint citizenId) + { + ref var citizen = ref CitizenManager.instance.m_citizens.m_buffer[citizenId]; + var newEducation = NewCitizenBehavior.GetEducation(citizen.Age, citizen.EducationLevel); + citizen.Education3 = newEducation == Citizen.Education.ThreeSchools; + citizen.Education2 = newEducation == Citizen.Education.TwoSchools || newEducation == Citizen.Education.ThreeSchools; + citizen.Education1 = newEducation != Citizen.Education.Uneducated; + } + + private sealed class CitizenManager_CreateCitizen1 : PatchBase + { + protected override MethodInfo GetMethod() + { + return typeof(CitizenManager).GetMethod( + "CreateCitizen", + BindingFlags.Instance | BindingFlags.Public, + null, + new[] { typeof(uint).MakeByRefType(), typeof(int), typeof(int), typeof(Randomizer).MakeByRefType() }, + new ParameterModifier[0]); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Redundancy", "RCS1213", Justification = "Harmony patch")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Naming Rules", "SA1313", Justification = "Harmony patch")] + private static void Postfix(ref uint citizen, bool __result) + { + if (__result) + { + // This method is called by the game in two cases only: a new child is born or a citizen joins the city. + // So we tailor the age here. + UpdateCizizenAge(citizen); + UpdateCitizenEducation(citizen); + } + } + } + + private sealed class CitizenManager_CreateCitizen2 : PatchBase + { + protected override MethodInfo GetMethod() + { + return typeof(CitizenManager).GetMethod( + "CreateCitizen", + BindingFlags.Instance | BindingFlags.Public, + null, + new[] { typeof(uint).MakeByRefType(), typeof(int), typeof(int), typeof(Randomizer).MakeByRefType(), typeof(Citizen.Gender) }, + new ParameterModifier[0]); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Redundancy", "RCS1213", Justification = "Harmony patch")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Naming Rules", "SA1313", Justification = "Harmony patch")] + private static void Postfix(ref uint citizen, bool __result) + { + if (__result) + { + UpdateCitizenEducation(citizen); + } + } + } + } +} diff --git a/src/RealTime/GameConnection/Patches/UIGraphPatches.cs b/src/RealTime/GameConnection/Patches/UIGraphPatch.cs similarity index 99% rename from src/RealTime/GameConnection/Patches/UIGraphPatches.cs rename to src/RealTime/GameConnection/Patches/UIGraphPatch.cs index 17c7a08a..8730d111 100644 --- a/src/RealTime/GameConnection/Patches/UIGraphPatches.cs +++ b/src/RealTime/GameConnection/Patches/UIGraphPatch.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) dymanoid. All rights reserved. // @@ -18,7 +18,7 @@ namespace RealTime.GameConnection.Patches /// /// A static class that provides the patch objects for the statistics graph. /// - internal static class UIGraphPatches + internal static class UIGraphPatch { private const int MinRangeInDays = 7; private const int MinPointsCount = 32; diff --git a/src/RealTime/GameConnection/Patches/WorldInfoPanelPatches.cs b/src/RealTime/GameConnection/Patches/WorldInfoPanelPatch.cs similarity index 95% rename from src/RealTime/GameConnection/Patches/WorldInfoPanelPatches.cs rename to src/RealTime/GameConnection/Patches/WorldInfoPanelPatch.cs index 989ac6b5..992ecb2a 100644 --- a/src/RealTime/GameConnection/Patches/WorldInfoPanelPatches.cs +++ b/src/RealTime/GameConnection/Patches/WorldInfoPanelPatch.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) dymanoid. All rights reserved. // @@ -12,7 +12,7 @@ namespace RealTime.GameConnection.Patches /// /// A static class that provides the patch objects for the world info panel game methods. /// - internal static class WorldInfoPanelPatches + internal static class WorldInfoPanelPatch { /// Gets or sets the customized citizen information panel. public static CustomCitizenInfoPanel CitizenInfoPanel { get; set; } diff --git a/src/RealTime/GameConnection/VanillaEventInfo.cs b/src/RealTime/GameConnection/VanillaEventInfo.cs new file mode 100644 index 00000000..91cae176 --- /dev/null +++ b/src/RealTime/GameConnection/VanillaEventInfo.cs @@ -0,0 +1,49 @@ +// +// Copyright (c) dymanoid. All rights reserved. +// + +namespace RealTime.GameConnection +{ + using System; + + /// + /// A ref-struct consolidating the information about a vanilla game event. + /// + internal readonly ref struct VanillaEventInfo + { + /// + /// Initializes a new instance of the struct. + /// + /// The ID of the building where the event takes place. + /// The event start date and time. + /// The event duration in hours. + /// The ticket price for the event. + public VanillaEventInfo(ushort buildingId, DateTime startTime, float duration, float ticketPrice) + { + BuildingId = buildingId; + StartTime = startTime; + Duration = duration; + TicketPrice = ticketPrice; + } + + /// + /// Gets the ID of the building where the event takes place. + /// + public ushort BuildingId { get; } + + /// + /// Gets the event start date and time. + /// + public DateTime StartTime { get; } + + /// + /// Gets the event duration in hours. + /// + public float Duration { get; } + + /// + /// Gets the ticket price for the event. + /// + public float TicketPrice { get; } + } +} diff --git a/src/RealTime/Localization/Translations/de.xml b/src/RealTime/Localization/Translations/de.xml index 8435ccdd..429b0361 100644 --- a/src/RealTime/Localization/Translations/de.xml +++ b/src/RealTime/Localization/Translations/de.xml @@ -10,6 +10,7 @@ + @@ -42,6 +43,8 @@ + + @@ -70,7 +73,7 @@ - + @@ -223,5 +226,5 @@ - + \ No newline at end of file diff --git a/src/RealTime/Localization/Translations/en.xml b/src/RealTime/Localization/Translations/en.xml index 6025563a..0b754b3f 100644 --- a/src/RealTime/Localization/Translations/en.xml +++ b/src/RealTime/Localization/Translations/en.xml @@ -2,7 +2,7 @@ - + @@ -10,6 +10,7 @@ + @@ -23,7 +24,7 @@ - + @@ -42,10 +43,12 @@ + + - + @@ -59,7 +62,7 @@ - + @@ -70,7 +73,7 @@ - + @@ -192,7 +195,7 @@ - + @@ -223,5 +226,5 @@ - + \ No newline at end of file diff --git a/src/RealTime/Localization/Translations/es.xml b/src/RealTime/Localization/Translations/es.xml index 2bac5dfb..ebec0746 100644 --- a/src/RealTime/Localization/Translations/es.xml +++ b/src/RealTime/Localization/Translations/es.xml @@ -10,6 +10,7 @@ + @@ -42,6 +43,8 @@ + + @@ -224,4 +227,4 @@ - + \ No newline at end of file diff --git a/src/RealTime/Localization/Translations/fr.xml b/src/RealTime/Localization/Translations/fr.xml index ac062121..25fd82e9 100644 --- a/src/RealTime/Localization/Translations/fr.xml +++ b/src/RealTime/Localization/Translations/fr.xml @@ -10,6 +10,7 @@ + @@ -42,9 +43,11 @@ + + - + @@ -58,7 +61,7 @@ - + @@ -70,7 +73,7 @@ - + @@ -88,7 +91,7 @@ - + @@ -224,4 +227,4 @@ - + \ No newline at end of file diff --git a/src/RealTime/Localization/Translations/it.xml b/src/RealTime/Localization/Translations/it.xml index 005a756f..7643fb72 100644 --- a/src/RealTime/Localization/Translations/it.xml +++ b/src/RealTime/Localization/Translations/it.xml @@ -2,26 +2,33 @@ + + + + + + + - + - + - + - + @@ -36,8 +43,12 @@ + + + + - + @@ -46,10 +57,12 @@ + + - + @@ -60,7 +73,7 @@ - + @@ -76,6 +89,21 @@ + + + + + + + + + + + + + + + @@ -167,7 +195,7 @@ - + @@ -197,14 +225,6 @@ - - - - - - - - - - - + + + \ No newline at end of file diff --git a/src/RealTime/Localization/Translations/ja.xml b/src/RealTime/Localization/Translations/ja.xml index fb124e36..5ac478fa 100644 --- a/src/RealTime/Localization/Translations/ja.xml +++ b/src/RealTime/Localization/Translations/ja.xml @@ -2,7 +2,7 @@ - + @@ -10,6 +10,7 @@ + @@ -23,7 +24,7 @@ - + @@ -42,10 +43,12 @@ + + - + @@ -59,7 +62,7 @@ - + @@ -70,7 +73,7 @@ - + @@ -192,7 +195,7 @@ - + @@ -223,5 +226,5 @@ - - + + \ No newline at end of file diff --git a/src/RealTime/Localization/Translations/ko.xml b/src/RealTime/Localization/Translations/ko.xml index 346a3fb5..235e03b4 100644 --- a/src/RealTime/Localization/Translations/ko.xml +++ b/src/RealTime/Localization/Translations/ko.xml @@ -10,6 +10,7 @@ + @@ -42,6 +43,8 @@ + + @@ -70,7 +73,7 @@ - + @@ -224,4 +227,4 @@ - + \ No newline at end of file diff --git a/src/RealTime/Localization/Translations/pl.xml b/src/RealTime/Localization/Translations/pl.xml index 8f6e6780..da396136 100644 --- a/src/RealTime/Localization/Translations/pl.xml +++ b/src/RealTime/Localization/Translations/pl.xml @@ -10,6 +10,7 @@ + @@ -42,6 +43,8 @@ + + @@ -70,7 +73,7 @@ - + @@ -224,4 +227,4 @@ - + \ No newline at end of file diff --git a/src/RealTime/Localization/Translations/pt.xml b/src/RealTime/Localization/Translations/pt.xml index e09735e1..2602fa8c 100644 --- a/src/RealTime/Localization/Translations/pt.xml +++ b/src/RealTime/Localization/Translations/pt.xml @@ -10,6 +10,7 @@ + @@ -42,6 +43,8 @@ + + @@ -70,7 +73,7 @@ - + @@ -224,4 +227,4 @@ - + \ No newline at end of file diff --git a/src/RealTime/Localization/Translations/ru.xml b/src/RealTime/Localization/Translations/ru.xml index f5408de2..1f7994de 100644 --- a/src/RealTime/Localization/Translations/ru.xml +++ b/src/RealTime/Localization/Translations/ru.xml @@ -10,6 +10,7 @@ + @@ -42,6 +43,8 @@ + + @@ -70,7 +73,7 @@ - + @@ -223,5 +226,5 @@ - + \ No newline at end of file diff --git a/src/RealTime/Localization/Translations/zh.xml b/src/RealTime/Localization/Translations/zh.xml index d07995f1..b9f003bd 100644 --- a/src/RealTime/Localization/Translations/zh.xml +++ b/src/RealTime/Localization/Translations/zh.xml @@ -10,6 +10,7 @@ + @@ -42,6 +43,8 @@ + + @@ -70,7 +73,7 @@ - + @@ -224,4 +227,4 @@ - + \ No newline at end of file diff --git a/src/RealTime/RealTime.csproj b/src/RealTime/RealTime.csproj index 9c753208..8fe8a8c2 100644 --- a/src/RealTime/RealTime.csproj +++ b/src/RealTime/RealTime.csproj @@ -73,10 +73,12 @@ + + @@ -92,6 +94,7 @@ + @@ -115,12 +118,13 @@ + - + - + @@ -139,6 +143,7 @@ + @@ -186,6 +191,12 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/src/RealTime/UI/CustomTimeBar.cs b/src/RealTime/UI/CustomTimeBar.cs index 16252e4d..e9bf68c6 100644 --- a/src/RealTime/UI/CustomTimeBar.cs +++ b/src/RealTime/UI/CustomTimeBar.cs @@ -27,9 +27,13 @@ internal sealed class CustomTimeBar private const string UILabelTime = "Time"; private const string UISpriteEvent = "Event"; - private static readonly Color32 EventColor = new Color32(180, 0, 90, 160); + private const byte EventSpriteOpacity = 128; + + private static readonly Color32 TimeLabelShadowColor = new Color32(32, 32, 32, 255); + private static readonly Vector2 TimeLabelShadowOffset = new Vector2(1f, -1f); private readonly List displayedEvents = new List(); + private readonly List displayedEventSprites = new List(); private readonly List eventsToDisplay = new List(); private CultureInfo currentCulture = CultureInfo.CurrentCulture; @@ -53,7 +57,8 @@ public void Enable(DateTime currentDate) } customDateTimeWrapper = new RealTimeUIDateTimeWrapper(currentDate); - originalWrapper = SetUIDateTimeWrapper(customDateTimeWrapper, customize: true); + originalWrapper = SetCustomUIDateTimeWrapper(customDateTimeWrapper, enableWrapper: true); + SetEventColorUpdater(progressSprite, enableUpdater: true); } /// @@ -68,7 +73,8 @@ public void Disable() } RemoveAllCityEvents(); - SetUIDateTimeWrapper(originalWrapper, customize: false); + SetCustomUIDateTimeWrapper(originalWrapper, enableWrapper: false); + SetEventColorUpdater(progressSprite, enableUpdater: false); originalWrapper = null; progressSprite = null; customDateTimeWrapper = null; @@ -132,6 +138,25 @@ public void UpdateEventsDisplay(IReadOnlyList availableEvents) } } + /// + /// Updates the colors of currently displayed event bars. + /// + public void UpdateEventsColors() + { + if (progressSprite == null) + { + return; + } + + foreach (var sprite in displayedEventSprites) + { + var cityEvent = (ICityEvent)sprite.objectUserData; + sprite.color = GetColor(cityEvent.Color, EventSpriteOpacity); + } + } + + private static Color32 GetColor(EventColor color, byte alpha) => new Color32(color.Red, color.Green, color.Blue, alpha); + private static bool EventListsEqual(List first, List second) { if (first.Count != second.Count) @@ -195,7 +220,7 @@ private static UISprite GetProgressSprite(UIPanel infoPanel) return progressSprite; } - private static void CustomizeTimePanel(UISprite progressSprite) + private static void SetCustomTimePanelLayout(UISprite progressSprite, bool enableCustomization) { UILabel dateLabel = progressSprite.Find(UILabelTime); if (dateLabel == null) @@ -204,22 +229,29 @@ private static void CustomizeTimePanel(UISprite progressSprite) return; } - dateLabel.autoSize = false; - dateLabel.size = progressSprite.size; - dateLabel.textAlignment = UIHorizontalAlignment.Center; - dateLabel.relativePosition = new Vector3(0, 0, 0); + dateLabel.autoSize = !enableCustomization; + dateLabel.isInteractive = !enableCustomization; + dateLabel.useDropShadow = enableCustomization; + if (enableCustomization) + { + dateLabel.size = progressSprite.size; + dateLabel.textAlignment = UIHorizontalAlignment.Center; + dateLabel.relativePosition = new Vector3(0, 0, 0); + dateLabel.dropShadowColor = TimeLabelShadowColor; + dateLabel.dropShadowOffset = TimeLabelShadowOffset; + } } - private static void SetTooltip(UIComponent component, CultureInfo cultureInfo, bool customize) + private static void SetCustomTooltip(UIComponent component, CultureInfo cultureInfo, bool enableTooltip) { DateTooltipBehavior tooltipBehavior = component.gameObject.GetComponent(); - if (tooltipBehavior == null && customize) + if (enableTooltip && tooltipBehavior == null) { tooltipBehavior = component.gameObject.AddComponent(); tooltipBehavior.IgnoredComponentNamePrefix = UISpriteEvent; tooltipBehavior.Translate(cultureInfo); } - else if (tooltipBehavior != null && !customize) + else if (!enableTooltip && tooltipBehavior != null) { UnityEngine.Object.Destroy(tooltipBehavior); } @@ -231,7 +263,21 @@ private static void TranslateTooltip(UIComponent tooltipParent, CultureInfo cult tooltipBehavior?.Translate(cultureInfo); } - private UIDateTimeWrapper SetUIDateTimeWrapper(UIDateTimeWrapper wrapper, bool customize) + private void SetEventColorUpdater(UIComponent parent, bool enableUpdater) + { + var updateBehavior = parent.gameObject.GetComponent(); + if (enableUpdater && updateBehavior == null) + { + updateBehavior = parent.gameObject.AddComponent(); + updateBehavior.TimeBar = this; + } + else if (!enableUpdater && updateBehavior != null) + { + UnityEngine.Object.Destroy(updateBehavior); + } + } + + private UIDateTimeWrapper SetCustomUIDateTimeWrapper(UIDateTimeWrapper wrapper, bool enableWrapper) { UIPanel infoPanel = UIView.Find(UIInfoPanel); if (infoPanel == null) @@ -243,12 +289,8 @@ private UIDateTimeWrapper SetUIDateTimeWrapper(UIDateTimeWrapper wrapper, bool c progressSprite = GetProgressSprite(infoPanel); if (progressSprite != null) { - SetTooltip(progressSprite, currentCulture, customize); - - if (customize) - { - CustomizeTimePanel(progressSprite); - } + SetCustomTooltip(progressSprite, currentCulture, enableWrapper); + SetCustomTimePanelLayout(progressSprite, enableWrapper); } return ReplaceUIDateTimeWrapperInPanel(infoPanel, wrapper); @@ -275,11 +317,32 @@ private void DisplayCityEvent(ICityEvent cityEvent, DateTime todayStart, DateTim eventSprite.height = progressSprite.height; eventSprite.width = endPosition - startPosition; eventSprite.fillDirection = UIFillDirection.Horizontal; - eventSprite.color = EventColor; + eventSprite.color = GetColor(cityEvent.Color, EventSpriteOpacity); eventSprite.fillAmount = 1f; + eventSprite.SendToBack(); eventSprite.objectUserData = cityEvent; eventSprite.eventClicked += EventSprite_Clicked; SetEventTooltip(eventSprite, todayStart, todayEnd); + + var spriteBounds = eventSprite.GetBounds(); + var overlappingEvents = displayedEventSprites + .Where(e => e.GetBounds().Intersects(spriteBounds)) + .ToList(); + + if (overlappingEvents.Count > 0) + { + overlappingEvents.Add(eventSprite); + var itemCount = overlappingEvents.Count; + var newSpriteHeight = progressSprite.height / itemCount; + for (int i = 0; i < itemCount; ++i) + { + var sprite = overlappingEvents[i]; + sprite.height = newSpriteHeight; + sprite.relativePosition = new Vector3(sprite.relativePosition.x, i * newSpriteHeight); + } + } + + displayedEventSprites.Add(eventSprite); } private void SetEventTooltip(UISprite eventSprite, DateTime todayStart, DateTime todayEnd) @@ -305,16 +368,13 @@ private void RemoveAllCityEvents() return; } - foreach (var cityEvent in displayedEvents) + foreach (var sprite in displayedEventSprites) { - UISprite sprite = progressSprite.Find(UISpriteEvent + cityEvent.BuildingId); - if (sprite != null) - { - sprite.eventClicked -= EventSprite_Clicked; - UnityEngine.Object.Destroy(sprite); - } + sprite.eventClicked -= EventSprite_Clicked; + UnityEngine.Object.Destroy(sprite); } + displayedEventSprites.Clear(); displayedEvents.Clear(); } @@ -327,5 +387,13 @@ private void EventSprite_Clicked(UIComponent component, UIMouseEventParameter ev } private void OnCityEventClick(ushort buildingId) => CityEventClick?.Invoke(this, new CustomTimeBarClickEventArgs(buildingId)); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Used as a Unity3D component")] + private sealed class EventColorsUpdateBehavior : MonoBehaviour + { + public CustomTimeBar TimeBar { get; set; } + + public void Update() => TimeBar?.UpdateEventsColors(); + } } } \ No newline at end of file diff --git a/src/SkyTools b/src/SkyTools index 49a0fd4c..0c6f45a2 160000 --- a/src/SkyTools +++ b/src/SkyTools @@ -1 +1 @@ -Subproject commit 49a0fd4c8285d89f44cd60dc466635ccb16acd07 +Subproject commit 0c6f45a2c5c093088e331c523ae3e91214f3cdc0