using ksBroadcastingNetwork; using ksBroadcastingNetwork.Structs; using ksBroadcastingTestClient.Broadcasting; using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.Linq; using System.Text; using System.Threading.Tasks; namespace ksBroadcastingTestClient.Autopilot { /// /// Similar to the Broadcasting view model, but is meant to debug and understand decisions made by the autopilot. /// Is a bit of code duplication, but I wanted this to act stand-alone so it can serve as a base for a good auto-cam mod /// public class AutopilotViewModel : KSObservableObject { public AutopilotWeightsViewModel WeightsVM { get; } = new AutopilotWeightsViewModel(); public ObservableCollection Cars { get; } = new ObservableCollection(); public TrackViewModel TrackVM { get => Get(); private set => Set(value); } public CameraManagementViewModel CamManagerVM { get; } = new CameraManagementViewModel(); public KSRelayCommand RequestFocusedCarCommand { get; } public KSRelayCommand ToggleAutopilotCommand { get; } public bool IsAutopilotActive { get => Get(); private set { Set(value); NotifyUpdate(nameof(AutopilotStateText)); } } public string AutopilotStateText { get => IsAutopilotActive ? "Stop" : "Auto"; } private List _clients = new List(); public List PressureCategoryWeights { get; } = new List(); public DateTime LastFocusChange { get => Get(); private set => Set(value); } public DateTime LastCameraSetChange { get => Get(); private set => Set(value); } public DateTime LastCameraChange { get => Get(); private set => Set(value); } public int FocusedCarIndex { get; private set; } public string ActiveCameraSet { get; private set; } public string ActiveCamera { get; private set; } public RaceSessionType SessionType { get => Get(); set => Set(value); } public string CurrentHudPage { get; private set; } public string AutopilotState { get => Get(); private set => Set(value); } public ObservableCollection MiniLog { get; } = new ObservableCollection(); public AutopilotViewModel() { RequestFocusedCarCommand = new KSRelayCommand(RequestFocusedCar); ToggleAutopilotCommand = new KSRelayCommand((o) => IsAutopilotActive = !IsAutopilotActive); LastFocusChange = DateTime.Now.AddSeconds(-10); foreach (var item in Enum.GetValues(typeof(CarWeightCategoryEnum)).Cast()) { var weight = new CarWeightCategoryViewModel(item); weight.RawValue = GetDefaultCategoryWeight(item); PressureCategoryWeights.Add(weight); } } private float GetDefaultCategoryWeight(CarWeightCategoryEnum item) { switch (item) { // Add cases for overrides, leave them out for a default of 100% default: return 1f; } } int cycles = 0; private void CalcCarRanks() { cycles++; // This is the one big responsibility. Once each realtime update cycle, we will walk through all the cars and try to figure out // what is the best car (and camera and everything) to focus right now // The obvious stuff is that we want a priority mix of // - close action // - watching the front guys // - distribute screentime across the field // - contacts and accidents // but also take more sophisticated stuff into consideration // - camera clipping, avoid too furious jumps // - action clipping - once we watch something, we want to stick there for moment at least // - camera selection: we should offer a healthy mix and shuffle in onboards, bonus points for understanding which camera would be nice for a given situations // - instant replays - in theory we should be able to jump to any contact happening, and selectively offer a replay of the seconds before. Same for overtakes we didn't cover // I do see multiple ways to implement this, the regular way would be to use any default decision making method - in the easiest way we could stack up categories and weights. // Due to the nature of the amount solutions we have, I spot a loophole and will (try to) abuse the big trick in genetic algorithms, just without the CPU intensive use. #region deeper thoughts about this // GA's have a black magic component where you only need to describe the fitness function of a given solution. If you do this well, you do not need to understand/implement tactics, // the GA will explore this. But as we only have a number of cars = number of solutions, we can simply bruteforce all scenarios (= amount of cars) and decide by fitness function. // That one has to express either the gain or pain if we focus that car right now. Sounds a lot more fancy than it is, but it's important to understand the perspective and feed the // data we need #endregion var trackPositionCarList = Cars.OrderByDescending(x=>x.TrackPosition).ToList(); // for the ease of use, we'll set the lead car to the front var maxRank = 0f; AutopilotCarViewModel maxRankCar = null; var focusChangedBeforeSeconds = (float)(DateTime.Now - LastFocusChange).TotalSeconds; var cameraChangedBeforeSeconds = (float)(DateTime.Now - LastCameraChange).TotalSeconds; var weightDict = PressureCategoryWeights.ToDictionary(x => x.Category, x => x.RawValue); var avgPressure = 0f; foreach (var item in trackPositionCarList) { item.CalcPressure(trackPositionCarList, TrackVM.TrackMeters, focusChangedBeforeSeconds, weightDict, CamManagerVM); if(maxRank < item.Pressure) { maxRank = item.Pressure; maxRankCar = item; } avgPressure += item.Pressure; } if (trackPositionCarList.Count > 0) avgPressure /= trackPositionCarList.Count; if (IsAutopilotActive) { // additionally, we will see what cam we want to use // In the first approach, we will ask if there is something to force - eg. TV cams we need to learn var forcedCamSet = CamManagerVM.GetForcedCameraSet(); if (forcedCamSet != null) { RequestCameraChange(forcedCamSet.Set, forcedCamSet.Name); } else if(CamManagerVM.TVCamLearningProgress == 1f) { AnyCamera preferredCamera; float rawWeight; maxRankCar.CalcPreferredCamera(trackPositionCarList, TrackVM.TrackMeters, cameraChangedBeforeSeconds, CamManagerVM, out preferredCamera, out rawWeight); var stateStr = ""; if (maxRankCar != null && !maxRankCar.HasFocus) stateStr = $"Want: {maxRankCar.CurrentDriverName} ({maxRank:P0})"; stateStr += preferredCamera != null ? $" Cam {rawWeight:P0}" : $"-"; AutopilotState = stateStr; var isFocusChange = maxRankCar != null && !maxRankCar.HasFocus && maxRankCar.Pressure > avgPressure * 0.15f && rawWeight > 0.2f; var isCameraChange = preferredCamera != null && rawWeight > 0.2f; var isHudChange = false; var targetHUD = ""; if(isCameraChange) { switch (preferredCamera.CamType) { // Onboards will get the basic hud, with rpm and such case CameraTypeEnum.RearWing: case CameraTypeEnum.Onboard: targetHUD = "Basic HUD"; isHudChange = true; break; // Default is the broadcasting hud case CameraTypeEnum.Tv1: case CameraTypeEnum.Tv2: case CameraTypeEnum.Helicam: case CameraTypeEnum.Pitlane: case CameraTypeEnum.Unknown: default: targetHUD = "Broadcasting"; isHudChange = true; break; } if (targetHUD == CurrentHudPage) isHudChange = false; } if(isFocusChange && isHudChange) { RequestFocusedCarAndCamera(maxRankCar, preferredCamera.Set, preferredCamera.Name); Log($"Requested Focus: {maxRankCar.CurrentDriverName} ({maxRank:P0}) with cam {preferredCamera.Set}/{preferredCamera.Name} ({rawWeight:P0})"); } else if(isFocusChange) { RequestFocusedCar(maxRankCar); Log($"Requested Focus: {maxRankCar.CurrentDriverName} ({maxRank:P0})"); } else if(isCameraChange) { RequestCameraChange(preferredCamera.Set, preferredCamera.Name); } if(false && isHudChange) { RequestHudPageChange(targetHUD); Log($"Requested HUD change: {targetHUD}"); } } } } private void RequestFocusedCarAndCamera(object obj, string camSet, string camera) { var car = obj as AutopilotCarViewModel; if (car != null) { foreach (var client in _clients) { // mssing readonly check, will skip this as the ACC client has to handle this as well client.SetFocus(Convert.ToUInt16(car.CarIndex), camSet, camera); } LastFocusChange = DateTime.Now; LastCameraChange = DateTime.Now; } } private void RequestFocusedCar(object obj) { var car = obj as AutopilotCarViewModel; if (car != null) { foreach (var client in _clients) { // mssing readonly check, will skip this as the ACC client has to handle this as well client.SetFocus(Convert.ToUInt16(car.CarIndex)); } LastFocusChange = DateTime.Now; } } private void RequestHudPageChange(string requestedHudPage) { if(requestedHudPage != CurrentHudPage) { foreach (var client in _clients) { // mssing readonly check, will skip this as the ACC client has to handle this as well client.RequestHUDPage(requestedHudPage); } } } private void RequestCameraChange(string camSet, string camera) { foreach (var client in _clients) { // mssing readonly check, will skip this as the ACC client has to handle this as well client.SetCamera(camSet, camera); } LastCameraChange = DateTime.Now; } internal void RegisterNewClient(ACCUdpRemoteClient newClient) { if (newClient.MsRealtimeUpdateInterval > 0) { // This client will send realtime updates, we should listen newClient.MessageHandler.OnTrackDataUpdate += MessageHandler_OnTrackDataUpdate; newClient.MessageHandler.OnEntrylistUpdate += MessageHandler_OnEntrylistUpdate; newClient.MessageHandler.OnRealtimeUpdate += MessageHandler_OnRealtimeUpdate; newClient.MessageHandler.OnRealtimeCarUpdate += MessageHandler_OnRealtimeCarUpdate; } _clients.Add(newClient.MessageHandler); } private void MessageHandler_OnTrackDataUpdate(string sender, TrackData trackUpdate) { if (TrackVM?.TrackId != trackUpdate.TrackId) { if (TrackVM != null) { TrackVM.OnRequestCameraChange -= RequestCameraChange; TrackVM.OnRequestHudPageChange -= RequestHudPageChange; } TrackVM = new TrackViewModel(trackUpdate.TrackId, trackUpdate.TrackName, trackUpdate.TrackMeters); TrackVM.OnRequestCameraChange += RequestCameraChange; TrackVM.OnRequestHudPageChange += RequestHudPageChange; } // The track cams may update in between TrackVM.Update(trackUpdate); CamManagerVM.Update(trackUpdate); } private void MessageHandler_OnEntrylistUpdate(string sender, CarInfo carUpdate) { AutopilotCarViewModel vm = Cars.SingleOrDefault(x => x.CarIndex == carUpdate.CarIndex); if (vm == null) { vm = new AutopilotCarViewModel(carUpdate.CarIndex); Cars.Add(vm); } vm.Update(carUpdate); } private void MessageHandler_OnRealtimeUpdate(string sender, RealtimeUpdate update) { if (TrackVM != null) TrackVM.Update(update); SessionType = update.SessionType; foreach (var carVM in Cars) { carVM.SetFocused(update.FocusedCarIndex); } if(FocusedCarIndex != update.FocusedCarIndex) { FocusedCarIndex = update.FocusedCarIndex; LastFocusChange = DateTime.Now; } if(ActiveCameraSet != update.ActiveCameraSet) { ActiveCameraSet = update.ActiveCameraSet; LastCameraSetChange = DateTime.Now; } if(ActiveCamera != update.ActiveCamera || ActiveCameraSet != update.ActiveCameraSet) { ActiveCamera = update.ActiveCamera; LastCameraChange = DateTime.Now; } CurrentHudPage = update.CurrentHudPage; CamManagerVM.RealtimeUpdate(update); try { if (TrackVM?.TrackMeters > 0) { var sortedCars = Cars.OrderByDescending(x => x.SplinePosition).ToArray(); if(sortedCars.Count() > 1) { for (int i = 1; i < sortedCars.Length; i++) { var carAhead = sortedCars[i - 1]; var carBehind = sortedCars[i]; var distance = calcMetersDistance(carAhead, carBehind, TrackVM.TrackMeters); carBehind.GapFrontMeters = distance; carAhead.GapRearMeters = distance; var combinedSpeedMS = (carAhead.Kmh + carBehind.Kmh) / 2f / 3.6f; if(combinedSpeedMS > 0.0001f) { carBehind.GapFrontSeconds = distance / combinedSpeedMS; carAhead.GapRearSeconds = distance / combinedSpeedMS; } else { carBehind.GapFrontSeconds = 999; carAhead.GapRearSeconds = 999; } } // then the first and last cars var distance2 = calcMetersDistance(sortedCars.First(), sortedCars.Last(), TrackVM.TrackMeters); sortedCars.First().GapFrontMeters = distance2; sortedCars.Last().GapRearMeters = distance2; } else { foreach (var item in sortedCars) { item.GapFrontMeters = TrackVM.TrackMeters; item.GapRearMeters = TrackVM.TrackMeters; } } } // A bit unfortunate, the RealtimeUpdate is happening before the carUpdates so we will lag a bit // but still it's the one place we know the carupdates are synched and the states are the most comparable // more sophisticated solution would be better, like resetting a counter here and push the calculation once we received all cars CalcCarRanks(); } catch (Exception ex) { Debug.WriteLine(ex.Message); } } private static float calcMetersDistance(AutopilotCarViewModel carAhead, AutopilotCarViewModel carBehind, float trackMeters) { var splineDistance = carAhead.SplinePosition - carBehind.SplinePosition; while (splineDistance < 0f) splineDistance += 1f; return splineDistance * trackMeters; } private void MessageHandler_OnRealtimeCarUpdate(string sender, RealtimeCarUpdate carUpdate) { var vm = Cars.FirstOrDefault(x => x.CarIndex == carUpdate.CarIndex); if (vm == null) { // Oh, we don't have this car yet. In this implementation, the Network protocol will take care of this // so hopefully we will display this car in the next cycles return; } vm.Update(carUpdate, TrackVM.TrackMeters); if(vm.HasFocus) CamManagerVM.RealtimeUpdateFocusedCar(vm.CarIndex, vm.SplinePosition, vm.Kmh); } private void Log(string msg) { MiniLog.Insert(0, msg); while (MiniLog.Count > 15) MiniLog.RemoveAt(MiniLog.Count - 1); } } }