diff --git a/.gitignore b/.gitignore index ff5aab4..1774014 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /StarterNG/obj /.idea /.vs +starter/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d333a83 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# StarterNG + +Launcher for the MaSzyna train simulator, written in C# with Avalonia. + +## Building a native (NativeAOT) build + +The project publishes as a self-contained NativeAOT binary — no .NET runtime is +needed on the target machine. The commands below are the same ones CI runs, see +[`.github/workflows/dotnet.yml`](.github/workflows/dotnet.yml). + +Requirements: + +- .NET SDK 9 +- Linux: a native toolchain and zlib headers — `sudo apt install clang zlib1g-dev` + (Debian/Ubuntu) or `sudo dnf install clang zlib-devel` (Fedora) +- Windows: the Visual Studio C++ build tools (Desktop development with C++) + +Restore for the target runtime first, then publish: + +```sh +dotnet restore StarterNG/StarterNG.csproj -r linux-x64 + +dotnet publish StarterNG/StarterNG.csproj \ + -c Release -r linux-x64 --self-contained true --no-restore \ + -p:PublishAot=true -p:PublishTrimmed=true \ + -o publish/linux-x64 +``` + +For a Windows build swap both `linux-x64` occurrences for `win-x64` (and the +line continuations for PowerShell backticks). NativeAOT does not cross-compile, +so the Windows package has to be built on Windows and the Linux one on Linux — +that is why CI uses a `windows-2022` and an `ubuntu-22.04` runner. + +The output directory is the whole distributable: the `Starter` executable, the +Skia/HarfBuzz native libraries and `startercfg/` with the translations and the +default key bindings. The `.dbg` (or `.pdb`) file next to it is debug symbols and +can be dropped from a release. Trim/AOT analysis warnings during the publish are +expected and do not fail the build. + +CI builds both runtimes on every push and pull request to `main` and `staging`, +and on manual dispatch. Pushes additionally replace a rolling pre-release with +the packaged artifacts — tagged `latest` for `main` and `staging` for `staging`. + +## Development build + +```sh +dotnet build StarterNG/StarterNG.csproj +``` diff --git a/StarterNG/App.axaml b/StarterNG/App.axaml index 8105cd8..25e004a 100644 --- a/StarterNG/App.axaml +++ b/StarterNG/App.axaml @@ -5,18 +5,12 @@ x:Class="StarterNG.App" x:CompileBindings="True" RequestedThemeVariant="Dark"> - - @@ -24,9 +18,6 @@ - #177f00 #136B00 #0F5600 @@ -39,5 +30,13 @@ + + 6 + 6 + 24 + 12 + 12 + 2 + 4 diff --git a/StarterNG/App.axaml.cs b/StarterNG/App.axaml.cs index 064e786..6053a20 100644 --- a/StarterNG/App.axaml.cs +++ b/StarterNG/App.axaml.cs @@ -9,6 +9,7 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Threading; using StarterNG.Classes; +using StarterNG.Infrastructure; using StarterNG.Services; namespace StarterNG; @@ -45,6 +46,30 @@ public partial class App : Application var resolvedName = Loc.Load(langNameOrCode); Settings.Instance.Language = resolvedName; + ApplyCulture(Loc.CurrentLangCode); + } + + private static void ApplyCulture(string langCode) + { + CultureInfo culture; + try + { + culture = langCode switch + { + "" or "en" => CultureInfo.InvariantCulture, + "cz" => CultureInfo.GetCultureInfo("cs"), + _ => CultureInfo.GetCultureInfo(langCode) + }; + } + catch (CultureNotFoundException) + { + culture = CultureInfo.InvariantCulture; + } + + CultureInfo.DefaultThreadCurrentCulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = culture; + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; } public override void OnFrameworkInitializationCompleted() @@ -56,13 +81,23 @@ public partial class App : Application desktop.ShutdownRequested += (_, _) => { - Settings.Instance.CaptureAndSave(); + try { Settings.Instance.CaptureAndSave(); } + catch (Exception ex) { Diagnostics.Log("Settings save", ex); } + Settings.Instance.DumpMissingVehicleLog(); - if (KeyboardConfig.Instance.Dirty) - KeyboardConfig.Instance.Save(); + + try + { + if (KeyboardConfig.Instance.Dirty) + KeyboardConfig.Instance.Save(); + } + catch (Exception ex) { Diagnostics.Log("Keyboard config save", ex); } + + Diagnostics.Flush(); }; SplashWindow? splash = null; + bool mainReady = false; var sw = System.Diagnostics.Stopwatch.StartNew(); Task.Run(async () => @@ -71,6 +106,8 @@ public partial class App : Application { Dispatcher.UIThread.Post(() => { + if (mainReady) + return; if (sw.ElapsedMilliseconds > 300 && splash == null) { @@ -83,14 +120,47 @@ public partial class App : Application }); GameData.Instance.Load(progress); - }).ContinueWith(_ => + }).ContinueWith(load => { - Dispatcher.UIThread.Post(() => + Dispatcher.UIThread.Post(async () => { - var main = new MainWindow(); + MainWindow main; + try + { + main = new MainWindow(); + } + catch (Exception ex) + { + Diagnostics.Log("Main window", ex); + Diagnostics.Flush(); + Console.Error.WriteLine(ex); + mainReady = true; + splash?.Close(); + desktop.Shutdown(1); + return; + } + + mainReady = true; desktop.MainWindow = main; main.Show(); splash?.Close(); + splash = null; + + if (load.IsFaulted && load.Exception is { } error) + { + var inner = error.GetBaseException(); + Diagnostics.Log("Game data load", inner); + await Diagnostics.ReportAsync( + $"{Loc["FaultLoadData"]}{Environment.NewLine}{Environment.NewLine}{Loc["FaultDetail"]} {inner.Message}"); + } + + var faults = Diagnostics.CheckInstallation(); + if (faults.Count > 0) + { + await Diagnostics.ReportAsync( + string.Join(Environment.NewLine, faults) + Environment.NewLine + Environment.NewLine + + Loc["FaultBadInstall"]); + } }); }); } diff --git a/StarterNG/Classes/GameData.cs b/StarterNG/Classes/GameData.cs index 72587c6..ba716e5 100644 --- a/StarterNG/Classes/GameData.cs +++ b/StarterNG/Classes/GameData.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -46,6 +46,17 @@ public sealed class GameData catch { } done++; } + try + { + int legacy = Vehicles.ImportLegacy(); + if (legacy > 0) + Infrastructure.Diagnostics.Log($"textures.txt: wczytano {legacy} tekstur"); + } + catch (Exception ex) + { + Infrastructure.Diagnostics.Log("textures.txt import", ex); + } + Vehicles.EndLoad(); VehicleDatabase.PreloadMiniIndex(miniDir); diff --git a/StarterNG/Classes/LegacyTextureDatabase.cs b/StarterNG/Classes/LegacyTextureDatabase.cs new file mode 100644 index 0000000..b633842 --- /dev/null +++ b/StarterNG/Classes/LegacyTextureDatabase.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace StarterNG.Classes; + +public static class LegacyTextureDatabase +{ + public static List EnumerateFiles(string dynamicRoot = "dynamic") + { + var files = new List(); + if (!Directory.Exists(dynamicRoot)) + return files; + + foreach (string group in Directory.EnumerateDirectories(dynamicRoot)) + foreach (string sub in Directory.EnumerateDirectories(group)) + { + string path = Path.Combine(sub, "textures.txt"); + if (File.Exists(path)) + files.Add(path); + } + return files; + } + + public static VehicleEntry? Parse(string path, string dynamicRoot = "dynamic") + { + string[] lines; + try + { + lines = File.ReadAllLines(path, Encoding.GetEncoding(1250)); + } + catch (Exception ex) + { + Infrastructure.Diagnostics.Log($"textures.txt {path}", ex); + return null; + } + + string dir = RelativeDirectory(path, dynamicRoot); + var entry = new VehicleEntry { Uuid = "legacy:" + dir }; + + bool archived = false; + string categorySign = "*"; + int setSize = 0; + var pending = new List(); + + foreach (string raw in lines) + { + string line = raw.Trim(); + if (line.Length == 0) continue; + + char c = line[0]; + if (c is '#' or '@' or '*') continue; + + if (line.StartsWith("$a", StringComparison.OrdinalIgnoreCase)) + { + archived = true; + continue; + } + + if (c == '^') + { + FlushSet(entry, pending, setSize); + setSize = line.Length > 1 && char.IsDigit(line[1]) ? line[1] - '0' : 0; + continue; + } + + if (line.StartsWith("!=", StringComparison.Ordinal)) + { + categorySign = line.Length > 2 ? line[2].ToString() : "*"; + continue; + } + + if (line.IndexOf('=') < 0) continue; + if (line.StartsWith("//", StringComparison.Ordinal)) continue; + + var texture = ParseEntryLine(line, dir, categorySign, archived, entry); + if (texture is null) continue; + + entry.Textures.Add(texture); + + if (setSize > 1) + { + pending.Add(texture.Uuid!); + if (pending.Count == setSize) + FlushSet(entry, pending, setSize); + } + } + + FlushSet(entry, pending, setSize); + return entry.Textures.Count > 0 ? entry : null; + } + + private static void FlushSet(VehicleEntry entry, List pending, int size) + { + if (pending.Count > 1) + entry.Sets.Add(new VehicleSet + { + Uuid = "legacy-set:" + pending[0], + Mode = "sequence", + Count = size > 0 ? size : pending.Count, + TextureRefs = new List(pending) + }); + pending.Clear(); + } + + private static VehicleTexture? ParseEntryLine(string line, string dir, + string categorySign, bool archived, VehicleEntry entry) + { + string desc = ""; + int comment = line.IndexOf("//", StringComparison.Ordinal); + if (comment >= 0) + { + desc = line[(comment + 2)..].Trim(); + line = line[..comment].Trim(); + } + + var parts = line.Split('='); + if (parts.Length < 2) return null; + + string skin = StripExtension(parts[0].Trim()); + if (skin.Length == 0) return null; + + var specs = new List<(string Model, string Mini, string MiniD)>(); + for (int i = 1; i < parts.Length; i++) + { + var f = parts[i].Split(','); + if (f.Length < 2) continue; + specs.Add((f[0].Trim(), f[1].Trim(), f.Length > 2 ? f[2].Trim() : "")); + } + if (specs.Count == 0) return null; + + var first = specs[0]; + string groupId = $"legacy:{dir}:{first.Mini}:{categorySign}:{(archived ? 1 : 0)}"; + + if (!entry.Groups.Exists(g => g.Id == groupId)) + entry.Groups.Add(new VehicleGroup + { + Id = groupId, + Category = categorySign, + Mini = first.Mini, + Archived = archived, + Implicit = true + }); + + var texture = new VehicleTexture + { + Uuid = $"legacy:{dir}{skin}", + Directory = dir, + Skinfile = skin, + Model = first.Model, + Group = groupId, + MiniRef = first.Mini, + TextureMini = string.IsNullOrEmpty(first.MiniD) ? null : first.MiniD, + Meta = ParseDescription(desc) + }; + + for (int i = 1; i < specs.Count; i++) + texture.Aliases.Add(new VehicleAlias + { + Model = specs[i].Model, + MiniRef = specs[i].Mini, + TextureMini = string.IsNullOrEmpty(specs[i].MiniD) ? null : specs[i].MiniD + }); + + return texture; + } + + private static VehicleMeta? ParseDescription(string desc) + { + if (string.IsNullOrWhiteSpace(desc)) return null; + + var f = desc.Replace('_', ' ').Split(','); + string At(int i) => i < f.Length ? f[i].Trim() : ""; + + return new VehicleMeta + { + Raw = desc, + Version = At(0), + Vehicle = At(1), + Operator = At(2), + Depot = At(3), + RevisionDate = At(4), + RevisionPlace = At(5), + TextureAuthor = At(6), + PhotoAuthor = At(7) + }; + } + + private static string StripExtension(string name) + { + int dot = name.LastIndexOf('.'); + return dot > 0 ? name[..dot] : name; + } + + private static string RelativeDirectory(string texturesPath, string dynamicRoot) + { + string dir = Path.GetDirectoryName(texturesPath) ?? ""; + string full = Path.GetFullPath(dir); + string root = Path.GetFullPath(dynamicRoot); + + string rel = full.StartsWith(root, StringComparison.OrdinalIgnoreCase) + ? full[root.Length..].TrimStart(Path.DirectorySeparatorChar, '/') + : dir; + + return rel.Replace('\\', '/').TrimEnd('/') + "/"; + } +} diff --git a/StarterNG/Classes/Physics.cs b/StarterNG/Classes/Physics.cs index 9dd14db..4b45078 100644 --- a/StarterNG/Classes/Physics.cs +++ b/StarterNG/Classes/Physics.cs @@ -24,6 +24,8 @@ public sealed class Physics public static void PreloadIndex(string dynamicRoot = "dynamic") => FizIndex(dynamicRoot); + public static int IndexedCount => _fizIndex?.Count ?? 0; + private static Dictionary FizIndex(string dynamicRoot) { if (_fizIndex != null) diff --git a/StarterNG/Classes/PresetStore.cs b/StarterNG/Classes/PresetStore.cs index cc48bb3..bd62d0f 100644 --- a/StarterNG/Classes/PresetStore.cs +++ b/StarterNG/Classes/PresetStore.cs @@ -130,7 +130,10 @@ public static class PresetStore added++; } } - catch { } + catch (Exception ex) + { + Infrastructure.Diagnostics.Log($"Import {path}", ex); + } return added; } diff --git a/StarterNG/Classes/Scenery.cs b/StarterNG/Classes/Scenery.cs index cc8a2ca..9c384cb 100644 --- a/StarterNG/Classes/Scenery.cs +++ b/StarterNG/Classes/Scenery.cs @@ -49,7 +49,11 @@ public class Scenery public double Temperature = 15; public int FogStart = 10; public int FogEnd = 2000; + + public const int FogMin = 10; + public const int FogMax = 10000; public double Overcast = 0; + public bool OvercastRandom; public bool HasWeather; @@ -61,6 +65,7 @@ public class Scenery private double _origTemperature = 15; private int _origFogEnd = 2000; private double _origOvercast; + private bool _origOvercastRandom; private readonly string _template; @@ -68,8 +73,6 @@ public class Scenery { get { - if (!string.IsNullOrWhiteSpace(Name)) - return SceneryI18n.T(Name); return System.IO.Path.GetFileNameWithoutExtension(Path); } } @@ -149,7 +152,7 @@ public class Scenery RangeMin = float.Parse(m.Groups[2].Value, inv), Name = name, DataFolder = m.Groups[4].Value, - SkinFile = m.Groups[5].Value, + SkinFile = Dynamic.StripSkinExtension(m.Groups[5].Value), MmdFile = m.Groups[6].Value, PathName = m.Groups[7].Value, Offset = float.Parse(m.Groups[8].Value, inv), @@ -356,8 +359,8 @@ public class Scenery int.TryParse(nums[3], out int fogStart) && int.TryParse(nums[4], out int fogEnd)) { - FogStart = Math.Clamp(fogStart, 10, 2500); - fogEnd = Math.Clamp(fogEnd, FogStart, 2500); + FogStart = Math.Clamp(fogStart, FogMin, FogMax); + fogEnd = Math.Clamp(fogEnd, FogStart, FogMax); FogEnd = fogEnd > FogStart ? Random.Shared.Next(FogStart, fogEnd + 1) : fogEnd; @@ -378,8 +381,17 @@ public class Scenery _origTemperature = Temperature; _origFogEnd = FogEnd; _origOvercast = Overcast; + _origOvercastRandom = OvercastRandom; } + public bool WeatherChanged => + Day != _origDay || + ScenarioTimeOverride != _origOverride || + Math.Abs(Temperature - _origTemperature) > 0.001 || + FogEnd != _origFogEnd || + OvercastRandom != _origOvercastRandom || + (!OvercastRandom && Math.Abs(Overcast - _origOvercast) > 0.001); + public void RestoreWeather() { Time = _origTime; @@ -388,15 +400,24 @@ public class Scenery Temperature = _origTemperature; FogEnd = _origFogEnd; Overcast = _origOvercast; + OvercastRandom = _origOvercastRandom; WeatherDirty = true; } + private static readonly double[] OvercastPresets = { -1.5, 0, 0.3, 0.7, 1, 1.1, 1.5 }; + + private double EffectiveOvercast() => + OvercastRandom ? OvercastPresets[Random.Shared.Next(OvercastPresets.Length)] : Overcast; + private string RewriteWeather(string text) { try { string s = text; + + string carried = CarriedConfigLines(s); + s = Regex.Replace(s, @"(?is)\bconfig\b.*?\bendconfig\b", " "); s = Regex.Replace(s, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " "); s = Regex.Replace(s, @"(?is)\batmo\b.*?\bendatmo\b", " "); @@ -410,10 +431,11 @@ public class Scenery "config\r\n" + $"movelight {Day}\r\n" + $"scenario.weather.temperature {Temperature.ToString(inv)}\r\n" + - $"scenario.time.override {ScenarioTimeOverride}\r\n" + + $"scenario.time.override {DelphiHourMinute(ScenarioTimeOverride)}\r\n" + + carried + "endconfig\r\n" + - $"atmo 0 0 0 {FogEnd} {FogEnd} 0 0 0 {Overcast.ToString(inv)} endatmo\r\n" + - $"time {Time} 0 0 endtime\r\n"; + $"atmo 0 0 0 {FogEnd} {FogEnd} 0 0 0 {EffectiveOvercast().ToString(inv)} endatmo\r\n" + + $"time {DelphiHourMinute(Time)} 0 0 endtime\r\n"; return config + s; } @@ -423,6 +445,36 @@ public class Scenery } } + private static string DelphiHourMinute(string hhmm) + { + int colon = hhmm.IndexOf(':'); + if (colon <= 0) return hhmm; + return hhmm[..colon].TrimStart('0') is { Length: > 0 } h + ? h + hhmm[colon..] + : "0" + hhmm[colon..]; + } + + private static readonly Regex ManagedConfigLine = new( + @"^\s*(movelight|scenario\.weather\.temperature|scenario\.time\.override)\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static string CarriedConfigLines(string text) + { + var block = Regex.Match(text, @"(?is)\bconfig\b(.*?)\bendconfig\b"); + if (!block.Success) + return ""; + + var sb = new StringBuilder(); + foreach (string raw in block.Groups[1].Value.Split('\n')) + { + string line = raw.TrimEnd('\r').Trim(); + if (line.Length == 0 || ManagedConfigLine.IsMatch(line)) + continue; + sb.Append(line).Append("\r\n"); + } + return sb.ToString(); + } + private static string MatchAllDirectives(string content, string letter) { var matches = Regex.Matches( diff --git a/StarterNG/Classes/Settings.cs b/StarterNG/Classes/Settings.cs index f5a6528..9e97875 100644 --- a/StarterNG/Classes/Settings.cs +++ b/StarterNG/Classes/Settings.cs @@ -95,6 +95,11 @@ public sealed class Settings public int VehiclesVolume = 100; public int PositionalVolume = 100; public int AmbientVolume = 100; + public int VolumePaused = 100; + public bool SkipPipeline; + public string PyScreenPriority = "normal"; + public bool DebugLogVisible; + private int _debugLogExtraBits; public bool CompressTextures = true; public bool ScaleSpeculars = true; @@ -130,6 +135,10 @@ public sealed class Settings private string _savePath = string.Empty; private DateTime _configAgeUtc = DateTime.MinValue; + public string LoadedFrom { get; private set; } = string.Empty; + + public string SavePath => _savePath; + public static string UserConfigPath() { if (OperatingSystem.IsWindows()) @@ -289,6 +298,7 @@ public sealed class Settings { _savePath = UserConfigPath(); string source = File.Exists(_savePath) ? _savePath : DefaultConfigPath(); + LoadedFrom = source; LoadFrom(source); } @@ -394,11 +404,12 @@ public sealed class Settings { var lines = GameData.Instance.Vehicles.CollectMissingAssetLines(); if (lines.Count == 0) return; - string dir = Path.Combine(Directory.GetCurrentDirectory(), "starter"); - Directory.CreateDirectory(dir); - File.WriteAllLines(Path.Combine(dir, "bledy.txt"), lines); + Infrastructure.Diagnostics.Log(lines); + } + catch (Exception ex) + { + Infrastructure.Diagnostics.Log("Missing vehicle log", ex); } - catch { } } private void ReadFromConfig() @@ -492,7 +503,10 @@ public sealed class Settings EnableTraction = c.GetBool("enabletraction", true); LiveTraction = c.GetBool("livetraction", true); PhysicsLog = c.GetBool("physicslog", false); - DebugLog = c.GetInt("debuglog", 3) != 0; + int debugLog = c.GetInt("debuglog", 3); + DebugLog = (debugLog & 1) != 0; + DebugLogVisible = (debugLog & 2) != 0; + _debugLogExtraBits = debugLog & ~3; MultipleLogs = c.GetBool("multiplelogs", false); DisplaySimulation = !c.GetBool("gfx.skiprendering", false); CrashDamage = c.GetBool("crashdamage", true); @@ -506,6 +520,9 @@ public sealed class Settings VehiclesVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.vehicle", 1.0) * 100), 1, 100); PositionalVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.positional", 1.0) * 100), 1, 100); AmbientVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.ambient", 1.0) * 100), 1, 100); + VolumePaused = Clamp((int)Math.Round(c.GetDouble("sound.volume.paused", 1.0) * 100), 0, 100); + SkipPipeline = c.GetBool("gfx.skippipeline", false); + PyScreenPriority = c.GetString("pyscreenrendererpriority", "normal"); CompressTextures = c.GetBool("compresstex", true); ScaleSpeculars = c.GetBool("scalespeculars", true); @@ -613,7 +630,8 @@ public sealed class Settings c.SetBool("enabletraction", EnableTraction); c.SetBool("livetraction", LiveTraction); c.SetBool("physicslog", PhysicsLog); - c.SetInt("debuglog", DebugLog ? 3 : 0); + c.SetInt("debuglog", + (DebugLog ? 1 : 0) | (DebugLogVisible ? 2 : 0) | _debugLogExtraBits); c.SetBool("multiplelogs", MultipleLogs); c.SetBool("gfx.skiprendering", !DisplaySimulation); c.SetBool("crashdamage", CrashDamage); @@ -627,6 +645,9 @@ public sealed class Settings c.SetDouble("sound.volume.vehicle", VehiclesVolume / 100.0); c.SetDouble("sound.volume.positional", PositionalVolume / 100.0); c.SetDouble("sound.volume.ambient", AmbientVolume / 100.0); + c.SetDouble("sound.volume.paused", VolumePaused / 100.0); + c.SetBool("gfx.skippipeline", SkipPipeline); + c.Set("pyscreenrendererpriority", PyScreenPriority); c.SetBool("compresstex", CompressTextures); c.SetBool("scalespeculars", ScaleSpeculars); diff --git a/StarterNG/Classes/Trainset.cs b/StarterNG/Classes/Trainset.cs index 17b003d..9a8b2a6 100644 --- a/StarterNG/Classes/Trainset.cs +++ b/StarterNG/Classes/Trainset.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -107,7 +107,7 @@ public class Trainset nodeDynamic.Name = tokens[++i]; i++; nodeDynamic.DataFolder = tokens[++i]; - nodeDynamic.SkinFile = tokens[++i]; + nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i]); nodeDynamic.MmdFile = tokens[++i]; nodeDynamic.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture); @@ -158,30 +158,7 @@ public class Trainset string entry = sb.ToString(); foreach (Dynamic vehicle in Vehicles) - { - string driverType = ""; - switch (vehicle.DriverType) - { - case eDriverType.Headdriver: - driverType = "headdriver"; - break; - case eDriverType.Reardriver: - driverType = "reardriver"; - break; - case eDriverType.Passenger: - driverType = "passenger"; - break; - default: - driverType = "nobody"; - break; - } - - entry += - $"node {vehicle.RangeMax} {vehicle.RangeMin} {vehicle.Name} dynamic " + - $"{vehicle.DataFolder} {vehicle.SkinFile} {vehicle.MmdFile} " + - $"{vehicle.Offset.ToString(CultureInfo.InvariantCulture)} " + - $"{driverType} {vehicle.Coupling}{vehicle.WriteTrailing()} enddynamic\n"; - } + entry += vehicle.ToTrainsetNode(); return entry; } @@ -207,7 +184,11 @@ public class Dynamic set => Coupling.Flags = value; } - public string? MaxLoad; + public const string PantState = "pantstate"; + + public const int PantStateMax = 3; + + public int MaxLoad = -1; public bool HasVelocity; @@ -217,25 +198,24 @@ public class Dynamic public string? LoadType; - public List Destinations = new(); - public string? MiniName; public bool Flipped; + public static bool IsPantStateType(string? type) => + string.Equals(type, PantState, StringComparison.OrdinalIgnoreCase); + + public bool IsPantState => IsPantStateType(LoadType); + internal void ReadTrailing(List t) { int p = 0; - if (p < t.Count && (t[p][0] == 'L' || t[p][0] == 'l') - && t[p].Length > 1 && char.IsDigit(t[p][1])) - MaxLoad = t[p++]; - - if (p < t.Count && float.TryParse(t[p], NumberStyles.Float, - CultureInfo.InvariantCulture, out float v)) + if (p < t.Count && t[p].Length > 0 && t[p][0] == 'L') { - Velocity = v; - HasVelocity = true; + if (int.TryParse(t[p].AsSpan(1), NumberStyles.Integer, + CultureInfo.InvariantCulture, out int ml)) + MaxLoad = ml; p++; } @@ -244,29 +224,30 @@ public class Dynamic { LoadCount = lc; p++; - if (lc > 0 && p < t.Count) + if (p < t.Count && (lc > 0 || IsPantStateType(t[p]))) LoadType = t[p++]; } - while (p < t.Count) - Destinations.Add(t[p++]); + } + + internal static string StripSkinExtension(string token) + { + int dot = token.LastIndexOf('.'); + return dot > 0 ? token[..dot] : token; } internal string WriteTrailing() { var sb = new StringBuilder(); - if (MaxLoad != null) - sb.Append(' ').Append(MaxLoad); - if (HasVelocity) - sb.Append(' ').Append(Velocity.ToString(CultureInfo.InvariantCulture)); - if (LoadCount > 0) - { - sb.Append(' ').Append(LoadCount.ToString(CultureInfo.InvariantCulture)); - if (!string.IsNullOrEmpty(LoadType)) - sb.Append(' ').Append(LoadType); - } - foreach (string d in Destinations) - sb.Append(' ').Append(d); + if (MaxLoad >= 0) + sb.Append(" L").Append(MaxLoad.ToString(CultureInfo.InvariantCulture)); + + bool loaded = !string.IsNullOrEmpty(LoadType); + sb.Append(' ').Append((loaded ? LoadCount : 0).ToString(CultureInfo.InvariantCulture)); + + if (loaded && (LoadCount > 0 || IsPantState)) + sb.Append(' ').Append(LoadType); + return sb.ToString(); } @@ -287,11 +268,27 @@ public class Dynamic Velocity = Velocity, LoadCount = LoadCount, LoadType = LoadType, - Destinations = new List(Destinations), MiniName = MiniName, Flipped = Flipped }; + public string ToTrainsetNode() + { + string driver = DriverType switch + { + eDriverType.Headdriver => "headdriver", + eDriverType.Reardriver => "reardriver", + eDriverType.Passenger => "passenger", + _ => "nobody" + }; + + return + $"node {RangeMax} {RangeMin} {Name} dynamic " + + $"{DataFolder} {SkinFile} {MmdFile} " + + $"{Offset.ToString(CultureInfo.InvariantCulture)} " + + $"{driver} {Coupling}{WriteTrailing()} enddynamic\n"; + } + public string ToLooseNode() { string driver = DriverType switch @@ -374,7 +371,7 @@ public sealed class Coupling public void SetBrake(BrakeSetting? brake) { Parameters.RemoveAll(p => p.StartsWith("B", StringComparison.Ordinal)); - if (brake != null) + if (brake is { IsEmpty: false }) Parameters.Insert(0, brake.ToParameter()); } @@ -417,34 +414,39 @@ public sealed class Coupling public sealed class BrakeSetting { - public static readonly string[] Modes = { "R+Mg", "G", "P", "R", "Q", "O", "A" }; + public static readonly string[] Modes = { "G", "P", "R", "M", "Q" }; - public string Mode = "P"; + public static readonly string[] Loads = { "T", "H", "F" }; + + public static readonly string[] Switches = { "0", "1" }; + + public string? Mode; public string? Load; public string? Switch; + public bool IsEmpty => Mode is null; + public static BrakeSetting? FromParameter(string? param) { - if (string.IsNullOrEmpty(param) || param![0] != 'B' || param.Length < 2) + if (string.IsNullOrEmpty(param) || char.ToUpperInvariant(param![0]) != 'B') return null; - string body = param.Substring(1); - string? mode = Modes.FirstOrDefault(m => body.StartsWith(m, StringComparison.Ordinal)); - if (mode is null) - return null; + string body = param.Substring(1).ToUpperInvariant(); + var b = new BrakeSetting + { + Mode = First(body, "M", "G", "P", "R", "Q"), + Load = First(body, Loads), + Switch = First(body, Switches) + }; + return b.Mode is null && b.Load is null && b.Switch is null ? null : b; - var b = new BrakeSetting { Mode = mode }; - int p = mode.Length; - if (p < body.Length && "THFA".IndexOf(body[p]) >= 0) - b.Load = body[p++].ToString(); - if (p < body.Length && "01A".IndexOf(body[p]) >= 0) - b.Switch = body[p].ToString(); - return b; + static string? First(string body, params string[] codes) => + Array.Find(codes, c => body.Contains(c, StringComparison.Ordinal)); } - public string ToParameter() => "B" + Mode + (Load ?? "") + (Switch ?? ""); + public string ToParameter() => "B" + Mode + Switch + Load; } public sealed class WheelSettings diff --git a/StarterNG/Classes/VehicleDatabase.cs b/StarterNG/Classes/VehicleDatabase.cs index 3ec29c2..d1f0f4c 100644 --- a/StarterNG/Classes/VehicleDatabase.cs +++ b/StarterNG/Classes/VehicleDatabase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -173,6 +173,26 @@ public class VehicleDatabase } } + public int ImportLegacy(string dynamicRoot = "dynamic") + { + int added = 0; + foreach (string file in LegacyTextureDatabase.EnumerateFiles(dynamicRoot)) + { + var entry = LegacyTextureDatabase.Parse(file, dynamicRoot); + if (entry is null) continue; + + entry.Textures.RemoveAll(t => + TextureBySkin.ContainsKey( + Path.GetFileNameWithoutExtension(t.Skinfile).ToLowerInvariant())); + + if (entry.Textures.Count == 0) continue; + + added += entry.Textures.Count; + Ingest(entry); + } + return added; + } + private void Ingest(VehicleEntry entry) { foreach (var group in entry.Groups) @@ -265,7 +285,7 @@ public class VehicleDatabase public string? ResolveMiniName(VehicleTexture texture) { - if (!string.IsNullOrEmpty(texture.TextureMini) && MiniPath(texture.TextureMini) != null) + if (HasMini(texture.TextureMini)) return texture.TextureMini; if (texture.Group != null && GroupsById.TryGetValue(texture.Group, out var grp) @@ -330,10 +350,26 @@ public class VehicleDatabase public static string? MiniPath(string? miniName, string miniDir = "textures/mini/") { - if (string.IsNullOrEmpty(miniName)) return null; - var index = _miniIndex ??= BuildMiniIndex(miniDir); - return index.TryGetValue(miniName!.ToLowerInvariant(), out var path) ? path : null; + + if (!string.IsNullOrEmpty(miniName) && + index.TryGetValue(miniName!.ToLowerInvariant(), out var path)) + return path; + + return FallbackMiniPath(miniDir); + } + + public static bool HasMini(string? miniName, string miniDir = "textures/mini/") + { + if (string.IsNullOrEmpty(miniName)) return false; + var index = _miniIndex ??= BuildMiniIndex(miniDir); + return index.ContainsKey(miniName!.ToLowerInvariant()); + } + + public static string? FallbackMiniPath(string miniDir = "textures/mini/") + { + var index = _miniIndex ??= BuildMiniIndex(miniDir); + return index.TryGetValue("other", out var other) ? other : null; } private static Dictionary BuildMiniIndex(string miniDir) diff --git a/StarterNG/Domain/TrainsetDisplay.cs b/StarterNG/Domain/TrainsetDisplay.cs index 50fb367..dfdd33f 100644 --- a/StarterNG/Domain/TrainsetDisplay.cs +++ b/StarterNG/Domain/TrainsetDisplay.cs @@ -19,7 +19,6 @@ public static class TrainsetDisplay if (IsEmpty(trainset)) return null; - var db = GameData.Instance.Vehicles; var sb = new StringBuilder(); string? prevKey = null; int run = 0; @@ -36,22 +35,16 @@ public static class TrainsetDisplay for (int i = 0; i < trainset.Vehicles.Count; i++) { var v = trainset.Vehicles[i]; - var tex = db.TextureForSkin(v.SkinFile); - string cat = tex?.ResolvedCategory ?? ""; - bool powered = cat is "e" or "s" or "p" or "z" or "a"; - if (i == 0 && powered) + if (i == 0 && IsPowered(v)) { - string head = !string.IsNullOrWhiteSpace(v.Name) ? v.Name! : - (!string.IsNullOrEmpty(tex?.ResolvedClass) ? tex!.ResolvedClass : BaseSkin(v)); - sb.Append(head); + sb.Append(VehicleLabel(v, head: true)); prevKey = null; run = 0; continue; } - string key = !string.IsNullOrEmpty(tex?.ResolvedClass) ? tex!.ResolvedClass - : (!string.IsNullOrWhiteSpace(v.Name) ? v.Name! : BaseSkin(v)); + string key = VehicleLabel(v); if (string.Equals(key, prevKey, StringComparison.OrdinalIgnoreCase)) { @@ -69,6 +62,18 @@ public static class TrainsetDisplay !string.IsNullOrWhiteSpace(v.Name) ? v.Name! : BaseSkin(v))); } + public static string VehicleLabel(Dynamic v, bool head = false) + { + var tex = GameData.Instance.Vehicles.TextureForSkin(v.SkinFile); + string? cls = string.IsNullOrEmpty(tex?.ResolvedClass) ? null : tex!.ResolvedClass; + string? name = string.IsNullOrWhiteSpace(v.Name) ? null : v.Name; + return (head ? name ?? cls : cls ?? name) ?? BaseSkin(v); + } + + public static bool IsPowered(Dynamic v) => + GameData.Instance.Vehicles.TextureForSkin(v.SkinFile)?.ResolvedCategory + is "e" or "s" or "p" or "z" or "a"; + private static string BaseSkin(Dynamic v) => string.IsNullOrWhiteSpace(v.SkinFile) ? "?" : System.IO.Path.GetFileNameWithoutExtension(v.SkinFile); @@ -87,6 +92,33 @@ public static class TrainsetDisplay return staffed?.Name ?? trainset.Vehicles[0].Name; } + public static List<(string Label, string Value)> StatsFields( + Trainset? trainset, Func physicsFor, + Func? categoryOf = null, + Func? loadWeightKg = null) + { + var fields = new List<(string, string)>(); + if (trainset is null) + return fields; + + var (lengthM, massKg, loadKg) = RecalcParams(trainset, physicsFor, categoryOf, loadWeightKg, allVehicles: false); + if (massKg < 200_000) + (lengthM, massKg, loadKg) = RecalcParams(trainset, physicsFor, categoryOf, loadWeightKg, allVehicles: true); + + var inv = CultureInfo.InvariantCulture; + + string track = trainset.Track ?? ""; + if (trainset.Velocity > 0) + track = $"{track} ({trainset.Velocity.ToString("0.#", inv)} km/h)"; + + fields.Add(($"{App.Loc["Mass"]} [t]:", (massKg / 1000.0).ToString("0.#", inv))); + fields.Add(($"{App.Loc["MassBrutto"]} [t]:", ((massKg + loadKg) / 1000.0).ToString("0.#", inv))); + fields.Add(($"{App.Loc["Length"]} [m]:", lengthM.ToString("0.#", inv))); + fields.Add(($"{App.Loc["VehicleCount"]}:", trainset.Vehicles.Count.ToString(inv))); + fields.Add(($"{App.Loc["Track"]}:", track)); + return fields; + } + public static string FormatStats(Trainset? trainset, Func physicsFor, string lengthLabel, string massLabel, string vehiclesLabel, string trackLabel, Func? categoryOf = null, @@ -154,7 +186,7 @@ public static class TrainsetDisplay private static bool LoadAccepted(Physics? p, string loadType) { if (p == null || string.IsNullOrWhiteSpace(p.LoadAccepted)) - return true; + return false; return p.LoadAccepted.Contains(loadType, StringComparison.OrdinalIgnoreCase); } @@ -180,20 +212,12 @@ public static class TrainsetDisplay if (mu) { foreach (var v in trainset.Vehicles) - { - if (!string.IsNullOrEmpty(v.Name)) - used.Remove(v.Name!); - v.Name = EnsureUnique(PreferredBaseName(v), used); - } + Rename(v, used); return target?.Name; } if (target != null) - { - if (!string.IsNullOrEmpty(target.Name)) - used.Remove(target.Name!); - target.Name = EnsureUnique(PreferredBaseName(target), used); - } + Rename(target, used); return target?.Name; } @@ -251,4 +275,21 @@ public static class TrainsetDisplay private static string SanitizeName(string name) => string.IsNullOrWhiteSpace(name) ? "vehicle" : name.Replace(' ', '_'); + + public static (string Base, string Suffix) SplitWagonNumber(string? name) + { + string n = name ?? ""; + int hash = n.LastIndexOf('#'); + if (hash >= 0 && hash + 1 < n.Length && n[(hash + 1)..].All(char.IsDigit)) + return (n[..hash], n[hash..]); + return (n, ""); + } + + private static void Rename(Dynamic v, HashSet used) + { + var (_, number) = SplitWagonNumber(v.Name); + if (!string.IsNullOrEmpty(v.Name)) + used.Remove(v.Name!); + v.Name = EnsureUnique(PreferredBaseName(v), used) + number; + } } diff --git a/StarterNG/Domain/UData.cs b/StarterNG/Domain/UData.cs new file mode 100644 index 0000000..7af9142 --- /dev/null +++ b/StarterNG/Domain/UData.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StarterNG.Classes; + +namespace StarterNG.Domain; + +public static class UData +{ + public readonly record struct TrainParams(double Length, double Mass, double LoadMass, int CountVehicles); + + public static int GetMaxCoupler(Dynamic v, Func physicsFor, bool leftCoupler = true) + { + var fiz = physicsFor(v); + if (fiz == null) return 3; + return leftCoupler + ? (v.Offset >= 0 ? fiz.AllowedFlagA : fiz.AllowedFlagB) + : (v.Offset >= 0 ? fiz.AllowedFlagB : fiz.AllowedFlagA); + } + + public static string GetControlType(Dynamic v, Func physicsFor, bool leftCoupler = true) + { + var fiz = physicsFor(v); + if (fiz == null) return ""; + return leftCoupler + ? (v.Offset >= 0 ? fiz.ControlTypeA : fiz.ControlTypeB) + : (v.Offset >= 0 ? fiz.ControlTypeB : fiz.ControlTypeA); + } + + public static List CheckFlag(int flag) + { + var result = new List(); + for (int f = 128; f >= 1; f >>= 1) + if (flag >= f) { result.Add(f); flag -= f; } + return result; + } + + public static int CommonCoupler(int c1, int c2) + { + var f1 = CheckFlag(c1); + var f2 = CheckFlag(c2); + return f1.Where(f2.Contains).Sum(); + } + + public static void AutoConnect(IList vehicles, int leftVehicle, + Func physicsFor) + { + if (leftVehicle < 0 || vehicles.Count - 1 <= leftVehicle) return; + + int leftMax = GetMaxCoupler(vehicles[leftVehicle], physicsFor, leftCoupler: false); + string controlType1 = GetControlType(vehicles[leftVehicle], physicsFor, leftCoupler: false); + + int rightMax = GetMaxCoupler(vehicles[leftVehicle + 1], physicsFor); + string controlType2 = GetControlType(vehicles[leftVehicle + 1], physicsFor); + + int coupler = CommonCoupler(leftMax, rightMax); + + const int F4 = 4; + if (CheckFlag(coupler).Contains(F4) && + !string.Equals(controlType1, controlType2, StringComparison.OrdinalIgnoreCase)) + coupler -= F4; + + vehicles[leftVehicle].Coupling.Flags = coupler; + } + + public static List GetMultiple(IList vehicles, int index, + Func setIdOf) + { + var result = new List { index }; + + bool Same(int a, int b) + { + string? x = setIdOf(vehicles[a]); + return x != null && x == setIdOf(vehicles[b]); + } + + int i = 0; + while (index - i > 0 && Same(index - i, index - i - 1)) + { + result.Add(index - i - 1); + i++; + } + + i = 0; + while (vehicles.Count - 1 > index + i && Same(index + i, index + i + 1)) + { + result.Add(index + i + 1); + i++; + } + return result; + } + + public static bool StaffedTrain(Trainset train) => + train.Vehicles.Count > 0 && + (IsDriver(train.Vehicles[0].DriverType) || IsDriver(train.Vehicles[^1].DriverType)); + + private static bool IsDriver(eDriverType d) => + d is eDriverType.Headdriver or eDriverType.Reardriver; + + public static bool IncludeVehicleToMass(Dynamic v, bool allVehicles, + Func physicsFor, Func categoryOf) + { + if (physicsFor(v) == null) return false; + if (allVehicles) return true; + + bool notDriver = v.DriverType is eDriverType.Passenger or eDriverType.Nobody; + string? cat = categoryOf(v); + return notDriver || cat is "z" or "a"; + } + + public static TrainParams RecalcTrainParams(Trainset? train, bool allVehicles, + Func physicsFor, Func categoryOf, + Func loadWeight) + { + double length = 0, mass = 0, loadMass = 0; + if (train == null) return new TrainParams(0, 0, 0, 0); + + for (int i = train.Vehicles.Count - 1; i >= 0; i--) + { + var v = train.Vehicles[i]; + var fiz = physicsFor(v); + + if (IncludeVehicleToMass(v, allVehicles, physicsFor, categoryOf) && fiz != null) + { + mass += fiz.Mass; + + if (!string.IsNullOrEmpty(v.LoadType) && + fiz.LoadAccepted.Contains(v.LoadType!, StringComparison.OrdinalIgnoreCase)) + loadMass += v.LoadCount * loadWeight(v.LoadType); + else if (!string.IsNullOrEmpty(v.LoadType)) + Infrastructure.Diagnostics.Log($"{v.Name} - nieobsługiwany ładunek pojazdu"); + } + + if (fiz != null) + length += fiz.Length; + } + return new TrainParams(length, mass, loadMass, train.Vehicles.Count); + } + + public static void RandomLoad(IEnumerable vehicles, + Func physicsFor, Random rng) + { + foreach (var v in vehicles) + { + var fiz = physicsFor(v); + if (fiz == null) continue; + + var loads = fiz.LoadAccepted + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .Where(x => x.Length > 0) + .ToList(); + + if (loads.Count > 0) + v.LoadType = loads[rng.Next(loads.Count)]; + } + } + + public static void ReverseMultiple(IList vehicles, int position, + Func setIdOf, Func physicsFor) + { + var indexes = GetMultiple(vehicles, position, setIdOf); + + foreach (int i in indexes) + vehicles[i].Offset = vehicles[i].Offset >= 0 ? -1f : 0f; + + indexes.Sort(); + + int first = indexes[0], last = indexes[^1]; + for (int i = 0; i < indexes.Count / 2; i++) + (vehicles[first + i], vehicles[last - i]) = (vehicles[last - i], vehicles[first + i]); + + foreach (int i in indexes) + AutoConnect(vehicles, i, physicsFor); + } + + public static bool CheckMoveVehicle(IList vehicles, int fromPos, int toPos, + Func setIdOf) + { + bool Same(int a, int b) + { + if (a < 0 || b < 0 || a >= vehicles.Count || b >= vehicles.Count) return false; + string? x = setIdOf(vehicles[a]); + return x != null && x == setIdOf(vehicles[b]); + } + + if (fromPos < toPos) + { + if (toPos > 0 && toPos < vehicles.Count - 1 && Same(toPos, toPos + 1)) + return false; + } + else if (toPos > 0 && Same(toPos, toPos - 1)) + { + return false; + } + return true; + } +} diff --git a/StarterNG/Domain/UDepot.cs b/StarterNG/Domain/UDepot.cs new file mode 100644 index 0000000..25beae8 --- /dev/null +++ b/StarterNG/Domain/UDepot.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using StarterNG.Classes; +using StarterNG.Infrastructure; + +namespace StarterNG.Domain; + +public static class UDepot +{ + public sealed class DepotTrain + { + public string TrainName = ""; + public List Vehicles = new(); + } + + public static string? FindDepotFile(string? path = null) + { + if (!string.IsNullOrEmpty(path)) + return File.Exists(path) ? path : null; + + string root = Directory.GetCurrentDirectory(); + foreach (string rel in new[] { Path.Combine("starter", "magazyn.ini"), "starter.ini", "RAINSTED.INI" }) + { + string full = Path.Combine(root, rel); + if (File.Exists(full)) return full; + } + return null; + } + + public static List ReadDepot(string? path = null) + { + var result = new List(); + string? file = FindDepotFile(path); + if (file == null) return result; + + string text; + try + { + text = File.ReadAllText(file, Encoding.GetEncoding(1250)); + } + catch (Exception ex) + { + Diagnostics.Log($"Błąd parsowania magazynu {file}", ex); + return result; + } + + DepotTrain? current = null; + int line = 0; + + foreach (string raw in text.Replace("\r\n", "\n").Split('\n')) + { + line++; + string s = raw.Trim(); + if (s.Length == 0) continue; + + var header = Regex.Match(s, @"^\[\s*TRAINSET\d+\s*(?:=\s*([^\]]*))?\]", RegexOptions.IgnoreCase); + if (header.Success) + { + current = new DepotTrain { TrainName = header.Groups[1].Value.Trim() }; + result.Add(current); + continue; + } + + if (current == null) continue; + + var entry = Regex.Match(s, @"^\d+\s*=\s*(.*)$"); + string body = entry.Success ? entry.Groups[1].Value.Trim() : s; + if (!body.StartsWith("node", StringComparison.OrdinalIgnoreCase)) continue; + + try + { + var v = ParseNode(body); + if (v != null) current.Vehicles.Add(v); + } + catch (Exception ex) + { + Diagnostics.Log($"Błąd parsowania magazynu. Linia: {line}", ex); + } + } + + result.RemoveAll(t => t.Vehicles.Count == 0); + return result; + } + + private static Dynamic? ParseNode(string body) + { + var t = body.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (t.Length < 10) return null; + + var inv = CultureInfo.InvariantCulture; + int p = 1; + + var d = new Dynamic + { + RangeMax = float.Parse(t[p++], inv), + RangeMin = float.Parse(t[p++], inv), + Name = t[p++] + }; + p++; + d.DataFolder = t[p++]; + d.SkinFile = Dynamic.StripSkinExtension(t[p++]); + d.MmdFile = t[p++]; + d.Offset = float.Parse(t[p++], inv); + d.DriverType = t[p++].ToLowerInvariant() switch + { + "headdriver" => eDriverType.Headdriver, + "reardriver" => eDriverType.Reardriver, + "passenger" => eDriverType.Passenger, + _ => eDriverType.Nobody + }; + d.Coupling = Coupling.Parse(t[p++]); + + var trailing = new List(); + while (p < t.Length && !string.Equals(t[p], "enddynamic", StringComparison.OrdinalIgnoreCase)) + trailing.Add(t[p++]); + d.ReadTrailing(trailing); + return d; + } + + public static bool SaveDepot(IReadOnlyList depot, string? path = null) + { + path ??= Path.Combine(Directory.GetCurrentDirectory(), "starter", "magazyn.ini"); + try + { + var sb = new StringBuilder(); + for (int i = 0; i < depot.Count; i++) + { + if (depot[i].Vehicles.Count == 0) continue; + + sb.Append('[').Append("TRAINSET").Append(i).Append('=') + .Append(depot[i].TrainName).Append("]\n"); + + for (int y = 0; y < depot[i].Vehicles.Count; y++) + sb.Append(y.ToString("00", CultureInfo.InvariantCulture)).Append('=') + .Append(depot[i].Vehicles[y].ToTrainsetNode()); + } + + string? dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.WriteAllText(path, sb.ToString(), Encoding.GetEncoding(1250)); + return true; + } + catch (Exception ex) + { + Diagnostics.Log("Błąd zapisu magazynu", ex); + return false; + } + } +} diff --git a/StarterNG/Infrastructure/Diagnostics.cs b/StarterNG/Infrastructure/Diagnostics.cs new file mode 100644 index 0000000..2630d7b --- /dev/null +++ b/StarterNG/Infrastructure/Diagnostics.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; +using StarterNG.Views; + +namespace StarterNG.Infrastructure; + +public static class Diagnostics +{ + private static readonly object Gate = new(); + private static readonly List Lines = new(); + + public static string LogPath => + Path.Combine(Directory.GetCurrentDirectory(), "starter", "bledy.txt"); + + public static string Text + { + get { lock (Gate) return string.Join(Environment.NewLine, Lines); } + } + + public static void Clear() + { + lock (Gate) Lines.Clear(); + } + + public static void Log(string message) + { + lock (Gate) + Lines.Add($"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)} {message}"); + } + + public static void Log(IEnumerable messages) + { + foreach (string message in messages) + Log(message); + } + + public static void Log(string context, Exception ex) => + Log($"{context}: {ex.GetType().Name}: {ex.Message}"); + + public static void Flush() + { + string[] snapshot; + lock (Gate) + { + if (Lines.Count == 0) return; + snapshot = Lines.ToArray(); + } + + try + { + string dir = Path.GetDirectoryName(LogPath)!; + Directory.CreateDirectory(dir); + File.WriteAllLines(LogPath, snapshot); + } + catch + { + } + } + + private static Window? Owner => + (Avalonia.Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime) + ?.MainWindow; + + public static Task ReportAsync(string message, string? title = null) + { + Log(message.Replace(Environment.NewLine, " | ")); + + var owner = Owner; + if (owner is null) + return Task.CompletedTask; + + return MessageBox.Show(owner, message, title ?? App.Loc["FaultTitle"], MessageBoxButtons.Ok); + } + + public static Task ReportAsync(string context, Exception ex, string? title = null) => + ReportAsync($"{context}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {ex.Message}", title); + + public static void ReportOnUiThread(string message, string? title = null) + { + if (Dispatcher.UIThread.CheckAccess()) + _ = ReportAsync(message, title); + else + Dispatcher.UIThread.Post(() => _ = ReportAsync(message, title)); + } + + public static List CheckInstallation() + { + var faults = new List(); + string root = Directory.GetCurrentDirectory(); + + foreach (string folder in new[] { "dynamic", "sounds", "models", "scenery", "textures" }) + if (!Directory.Exists(Path.Combine(root, folder))) + faults.Add(string.Format(App.Loc["FaultNoDir"], "/" + folder)); + + if (!File.Exists(Path.Combine(root, "data", "load_weights.txt"))) + faults.Add(App.Loc["FaultNoWeights"]); + + if (Classes.GameData.Instance.Sceneries.Count == 0) + faults.Add(App.Loc["FaultNoScenery"]); + + if (Classes.GameData.Instance.Vehicles.Textures.Count == 0) + faults.Add(App.Loc["FaultNoVehicles"]); + + if (Classes.Physics.IndexedCount == 0) + faults.Add(App.Loc["FaultNoPhysics"]); + + if (Classes.Settings.Instance.ResolveExecutable(out var problem) is var _ && + problem == Classes.ExeProblem.NotFound) + faults.Add(App.Loc["FaultNoExe"]); + + return faults; + } +} diff --git a/StarterNG/Infrastructure/DropGap.cs b/StarterNG/Infrastructure/DropGap.cs new file mode 100644 index 0000000..3c5bc82 --- /dev/null +++ b/StarterNG/Infrastructure/DropGap.cs @@ -0,0 +1,67 @@ +using System; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace StarterNG.Infrastructure; + +public sealed class DropGap +{ + private readonly Border _gap; + private int _index = -1; + + public DropGap(int milliseconds = 130) + { + _gap = new Border + { + Width = 0, + IsHitTestVisible = false, + Transitions = new Transitions + { + new DoubleTransition + { + Property = Layoutable.WidthProperty, + Duration = TimeSpan.FromMilliseconds(milliseconds), + Easing = new CubicEaseOut() + } + } + }; + } + + public bool IsGap(Control control) => ReferenceEquals(control, _gap); + + public void Show(Panel panel, int childIndex, double width) + { + int target = Math.Clamp(childIndex, 0, ChildCountWithoutGap(panel)); + + if (_index != target) + { + panel.Children.Remove(_gap); + panel.Children.Insert(Math.Clamp(target, 0, panel.Children.Count), _gap); + _index = target; + } + + _gap.Width = Math.Max(width, 8); + } + + public void Hide(Panel panel) + { + if (_index < 0) return; + + _gap.Width = 0; + panel.Children.Remove(_gap); + _index = -1; + } + + public void Forget() => _index = -1; + + private int ChildCountWithoutGap(Panel panel) + { + int count = panel.Children.Count; + foreach (var child in panel.Children) + if (ReferenceEquals(child, _gap)) + return count - 1; + return count; + } +} diff --git a/StarterNG/Infrastructure/DropIndexTracker.cs b/StarterNG/Infrastructure/DropIndexTracker.cs new file mode 100644 index 0000000..2d0ff54 --- /dev/null +++ b/StarterNG/Infrastructure/DropIndexTracker.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; + +namespace StarterNG.Infrastructure; + +public sealed class DropIndexTracker +{ + private const double Hysteresis = 14; + private static readonly TimeSpan Quiet = TimeSpan.FromMilliseconds(60); + + private readonly List _midpoints = new(); + private DateTime _changedAt = DateTime.MinValue; + + public int Index { get; private set; } = -1; + + public void Capture(IEnumerable midpoints) + { + _midpoints.Clear(); + _midpoints.AddRange(midpoints); + Index = -1; + _changedAt = DateTime.MinValue; + } + + public void Reset() + { + _midpoints.Clear(); + Index = -1; + } + + public bool Update(double x) + { + int candidate = Index; + + if (candidate < 0) + { + candidate = 0; + while (candidate < _midpoints.Count && x > _midpoints[candidate]) + candidate++; + } + else + { + while (candidate < _midpoints.Count && x > _midpoints[candidate] + Hysteresis) + candidate++; + while (candidate > 0 && x < _midpoints[candidate - 1] - Hysteresis) + candidate--; + } + + if (candidate == Index) + return false; + + var now = DateTime.UtcNow; + if (Index >= 0 && now - _changedAt < Quiet) + return false; + + _changedAt = now; + Index = candidate; + return true; + } +} diff --git a/StarterNG/Infrastructure/EdgeScroller.cs b/StarterNG/Infrastructure/EdgeScroller.cs new file mode 100644 index 0000000..8f662d5 --- /dev/null +++ b/StarterNG/Infrastructure/EdgeScroller.cs @@ -0,0 +1,63 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Threading; + +namespace StarterNG.Infrastructure; + +public sealed class EdgeScroller +{ + private const double Zone = 56; + private const double MaxStep = 18; + + private readonly DispatcherTimer _timer; + private ScrollViewer? _target; + private double _step; + + public EdgeScroller() + { + _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + _timer.Tick += (_, _) => Step(); + } + + public void Update(ScrollViewer scroll, double pointerX) + { + double width = scroll.Viewport.Width; + + _step = pointerX < Zone + ? -MaxStep * Math.Clamp((Zone - pointerX) / Zone, 0, 1) + : pointerX > width - Zone + ? MaxStep * Math.Clamp((pointerX - (width - Zone)) / Zone, 0, 1) + : 0; + + _target = scroll; + + if (_step == 0) + _timer.Stop(); + else if (!_timer.IsEnabled) + _timer.Start(); + } + + public void Stop() + { + _timer.Stop(); + _target = null; + _step = 0; + } + + private void Step() + { + if (_target is null || _step == 0) + { + _timer.Stop(); + return; + } + + double max = Math.Max(0, _target.Extent.Width - _target.Viewport.Width); + double x = Math.Clamp(_target.Offset.X + _step, 0, max); + if (Math.Abs(x - _target.Offset.X) < 0.01) + return; + + _target.Offset = new Vector(x, _target.Offset.Y); + } +} diff --git a/StarterNG/Infrastructure/LoadingScreen.cs b/StarterNG/Infrastructure/LoadingScreen.cs index 107438d..97ea515 100644 --- a/StarterNG/Infrastructure/LoadingScreen.cs +++ b/StarterNG/Infrastructure/LoadingScreen.cs @@ -29,9 +29,9 @@ public static class LoadingScreen WriteBmp(src, dest); } - catch + catch (Exception ex) { - + Diagnostics.Log("Loading screen logo", ex); } } diff --git a/StarterNG/Infrastructure/StatsBar.cs b/StarterNG/Infrastructure/StatsBar.cs new file mode 100644 index 0000000..ca9ada5 --- /dev/null +++ b/StarterNG/Infrastructure/StatsBar.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace StarterNG.Infrastructure; + +public static class StatsBar +{ + public static void Fill(Panel host, IReadOnlyList<(string Label, string Value)> fields) + { + host.Children.Clear(); + foreach (var (label, value) in fields) + { + var pair = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 5, + Margin = new Avalonia.Thickness(0, 0, 22, 0), + VerticalAlignment = VerticalAlignment.Center + }; + pair.Children.Add(new TextBlock + { + Text = label, + FontSize = 12, + Opacity = 0.65, + VerticalAlignment = VerticalAlignment.Center + }); + pair.Children.Add(new TextBlock + { + Text = value, + FontSize = 12, + FontWeight = FontWeight.SemiBold, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis + }); + host.Children.Add(pair); + } + } +} diff --git a/StarterNG/MainWindow.axaml b/StarterNG/MainWindow.axaml index 833329f..6d5917b 100644 --- a/StarterNG/MainWindow.axaml +++ b/StarterNG/MainWindow.axaml @@ -10,36 +10,21 @@ xmlns:starterNg="clr-namespace:StarterNG" Title="Starter MaSzyna" Icon="/Assets/starter.ico" - Width="1200" Height="768" MinWidth="1200" MinHeight="666" + Width="1010" Height="920" MinWidth="1010" MinHeight="920" WindowStartupLocation="CenterScreen"> - - - + - - + - - - - - + - - - + + - - @@ -92,18 +73,22 @@ + + - - -