diff --git a/StarterNG.sln b/StarterNG.sln index 4165ee9..7afd782 100644 --- a/StarterNG.sln +++ b/StarterNG.sln @@ -25,4 +25,8 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(RiderSharedRunConfigurations) = postSolution + File = linuix.run\StarterNG (linux64).run.xml + File = windows.run\StarterNG (win64).run.xml + EndGlobalSection EndGlobal diff --git a/StarterNG/Classes/GameData.cs b/StarterNG/Classes/GameData.cs index f846389..ae17654 100644 --- a/StarterNG/Classes/GameData.cs +++ b/StarterNG/Classes/GameData.cs @@ -62,6 +62,10 @@ public sealed class GameData // first thumbnail render doesn't scan textures/mini/ on the UI thread. VehicleDatabase.PreloadMiniIndex(miniDir); + // Likewise index every .fiz under dynamic/ once, so the consist's physics + // (length / mass / couplings) resolve without a UI-thread directory scan. + Physics.PreloadIndex(); + // Phase 2 - sceneries foreach (string file in scnFiles) { diff --git a/StarterNG/Classes/Physics.cs b/StarterNG/Classes/Physics.cs new file mode 100644 index 0000000..3d65f63 --- /dev/null +++ b/StarterNG/Classes/Physics.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace StarterNG.Classes; + +/// +/// Vehicle physics read from a .fiz file (dynamic/<dir>/<model>.fiz), mirroring +/// the original Starter's TLexParser.ParsePhysics. Provides mass, top speed, +/// length, accepted loads and the coupler AllowedFlag / ControlType used for the +/// automatic coupling calculation. +/// +public sealed class Physics +{ + public double Mass; // kg, as written in the .fiz + public double VMax; // km/h + public double Length; // metres + public string LoadAccepted = ""; + public int MaxLoad; + public int AllowedFlagA = 3; + public int AllowedFlagB = 3; + public string ControlTypeA = ""; + public string ControlTypeB = ""; + + private static readonly Dictionary Cache = new(StringComparer.OrdinalIgnoreCase); + + // Index of every .fiz under dynamic/, keyed by its file name without extension + // (e.g. "en57", "en57dumb"). Built once - the directory layout of the data + // folder vs the scenery's mmd token is unreliable, so we resolve by model name. + private static Dictionary? _fizIndex; + + /// Builds the .fiz index up front (call from the startup load). + public static void PreloadIndex(string dynamicRoot = "dynamic") => FizIndex(dynamicRoot); + + private static Dictionary FizIndex(string dynamicRoot) + { + if (_fizIndex != null) + return _fizIndex; + + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + if (Directory.Exists(dynamicRoot)) + foreach (string f in Directory.EnumerateFiles(dynamicRoot, "*.fiz", SearchOption.AllDirectories)) + index[Path.GetFileNameWithoutExtension(f)] = f; + } + catch { /* unreadable dynamic/ - no physics */ } + + _fizIndex = index; + return index; + } + + /// + /// Loads (and caches) the physics for a model name (the .fiz is located by name + /// anywhere under dynamic/). The is unused now but + /// kept for call-site clarity. Returns null when nothing matches. Never throws. + /// + public static Physics? For(string? dataFolder, string? model, string dynamicRoot = "dynamic") + { + if (string.IsNullOrEmpty(model)) + return null; + + string m = Path.GetFileNameWithoutExtension(model!); // strip any .mmd/.t3d/etc. + if (m.Length == 0) + return null; + + if (Cache.TryGetValue(m, out var cached)) + return cached; + + var index = FizIndex(dynamicRoot); + string? path = null; + if (index.TryGetValue(m, out var p1)) path = p1; + else if (index.TryGetValue(m + "dumb", out var p2)) path = p2; + + Physics? phys = null; + if (path != null) + { + phys = new Physics { AllowedFlagB = -1 }; // -1 = "B end not declared yet" + try { Parse(phys, path, Path.GetDirectoryName(path) ?? "", null, 0); } + catch { /* partial data still useful */ } + + // The original copies the A-end coupler onto the B-end when only one + // BuffCoupl section is declared. + if (phys.AllowedFlagB == -1) + { + phys.AllowedFlagB = phys.AllowedFlagA; + if (string.IsNullOrEmpty(phys.ControlTypeB)) + phys.ControlTypeB = phys.ControlTypeA; + } + } + + Cache[m] = phys; + return phys; + } + + private static readonly HashSet KnownKeys = new(StringComparer.OrdinalIgnoreCase) + { + "M", "Vmax", "L", "LoadAccepted", "MaxLoad", "AllowedFlag", "ControlType" + }; + + private static void Parse(Physics p, string path, string dir, string[]? prms, int depth) + { + if (depth > 6 || !File.Exists(path)) + return; + + var tokens = Tokenize(File.ReadAllText(path, Encoding.GetEncoding(1250))); + + string section = ""; + string? pendingKey = null; + + for (int i = 0; i < tokens.Count; i++) + { + string tok = tokens[i]; + if (tok == "=") + continue; + + // include end -> parse the included physics + if (tok.Equals("include", StringComparison.OrdinalIgnoreCase)) + { + if (++i >= tokens.Count) break; + string incFile = Resolve(tokens[i], prms); + var incParams = new List(); + i++; + while (i < tokens.Count && !tokens[i].Equals("end", StringComparison.OrdinalIgnoreCase)) + incParams.Add(Resolve(tokens[i++], prms)); + Parse(p, Path.Combine(dir, incFile), dir, incParams.ToArray(), depth + 1); + pendingKey = null; + continue; + } + + int eq = tok.IndexOf('='); + if (eq >= 0) + { + string k = tok[..eq].TrimStart('.').Trim(); + string v = tok[(eq + 1)..].Trim(); + if (v.Length == 0) { pendingKey = k; continue; } + Apply(p, section, k, Resolve(v, prms)); + pendingKey = null; + continue; + } + + string t = tok.TrimStart('.').Trim(); + if (pendingKey != null) + { + Apply(p, section, pendingKey, Resolve(t, prms)); + pendingKey = null; + } + else if (KnownKeys.Contains(t)) + { + pendingKey = t; // value comes in a following token (spaced "Key = value") + } + else + { + // any other bare identifier is a section header (Param, Load, + // Dimensions, BuffCoupl/1/2, or one we don't read). Keep only + // letters/digits so "Param:" or "Param," still match. + section = new string(t.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant(); + } + } + } + + private static void Apply(Physics p, string section, string key, string value) + { + switch (section) + { + case "param": + if (key.Equals("M", StringComparison.OrdinalIgnoreCase)) p.Mass = ParseD(value, p.Mass); + else if (key.Equals("Vmax", StringComparison.OrdinalIgnoreCase)) p.VMax = ParseD(value, p.VMax); + break; + case "load": + if (key.Equals("LoadAccepted", StringComparison.OrdinalIgnoreCase)) p.LoadAccepted = value; + else if (key.Equals("MaxLoad", StringComparison.OrdinalIgnoreCase)) p.MaxLoad = (int)ParseD(value, p.MaxLoad); + break; + case "dimensions": + if (key.Equals("L", StringComparison.OrdinalIgnoreCase)) p.Length = ParseD(value, p.Length); + break; + case "buffcoupl": + case "buffcoupl1": + if (key.Equals("AllowedFlag", StringComparison.OrdinalIgnoreCase)) p.AllowedFlagA = Flag(value, p.AllowedFlagA); + else if (key.Equals("ControlType", StringComparison.OrdinalIgnoreCase)) p.ControlTypeA = value; + break; + case "buffcoupl2": + if (key.Equals("AllowedFlag", StringComparison.OrdinalIgnoreCase)) p.AllowedFlagB = Flag(value, p.AllowedFlagB); + else if (key.Equals("ControlType", StringComparison.OrdinalIgnoreCase)) p.ControlTypeB = value; + break; + } + } + + // A negative AllowedFlag means a permanent coupling: abs + 128 (workshop-lock bit). + private static int Flag(string v, int fallback) + => int.TryParse(v, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out int f) + ? (f < 0 ? -f + 128 : f) + : fallback; + + private static double ParseD(string v, double fallback) + => double.TryParse(v, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out double d) + ? d + : fallback; + + // Substitutes an include placeholder like "(c1)" with the matching include + // parameter (1-based), otherwise returns the token unchanged. + private static string Resolve(string token, string[]? prms) + { + if (prms == null || token.Length < 3 || token[0] != '(' || token[^1] != ')') + return token; + string digits = new string(token.Where(char.IsDigit).ToArray()); + return int.TryParse(digits, out int idx) && idx >= 1 && idx <= prms.Length + ? prms[idx - 1] + : token; + } + + private static List Tokenize(string text) + { + var sb = new StringBuilder(); + foreach (var line in text.Split('\n')) + { + string l = line; + int hash = l.IndexOf('#'); + if (hash >= 0) l = l[..hash]; + sb.Append(l).Append(' '); + } + return sb.ToString() + .Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .ToList(); + } +} diff --git a/StarterNG/Classes/Settings.cs b/StarterNG/Classes/Settings.cs index b2c79be..4e4ccaa 100644 --- a/StarterNG/Classes/Settings.cs +++ b/StarterNG/Classes/Settings.cs @@ -153,6 +153,53 @@ public sealed class Settings private static string DefaultConfigPath() => Path.Combine(Directory.GetCurrentDirectory(), "eu07.ini"); + /// + /// The simulator executable to launch. With auto-selection on, the working + /// directory is scanned for an eu07* binary - eu07 on Linux/macOS, + /// eu07.exe on Windows - so the same procedure works on every OS. With it + /// off, the explicitly chosen path is used. + /// + public string ResolveExecutable() + { + if (!SelectExeAutomatically && !string.IsNullOrWhiteSpace(ExecutablePath)) + return ExecutablePath; + + string canonical = OperatingSystem.IsWindows() ? "eu07.exe" : "eu07"; + try + { + string? best = null; + foreach (string path in Directory.GetFiles(Directory.GetCurrentDirectory(), "eu07*")) + { + if (!IsExecutableCandidate(path)) + continue; + string file = Path.GetFileName(path); + if (string.Equals(file, canonical, StringComparison.OrdinalIgnoreCase)) + return path; // exact canonical name wins + if (best == null || file.Length < Path.GetFileName(best).Length) + best = path; // otherwise prefer the shortest eu07* name + } + if (best != null) + return best; + } + catch { /* fall through to the canonical name */ } + + return canonical; + } + + // Filters the eu07* matches down to plausible launcher binaries, skipping data + // and config files (eu07.ini, eu07.log, libraries, …). + private static bool IsExecutableCandidate(string path) + { + string ext = Path.GetExtension(path).ToLowerInvariant(); + if (ext is ".ini" or ".log" or ".txt" or ".cfg" or ".config" or ".json" + or ".dll" or ".so" or ".dat" or ".bak" or ".csv" or ".xml") + return false; + if (OperatingSystem.IsWindows()) + return ext == ".exe"; + // Linux / macOS: no extension (eu07) or a known binary suffix + return ext.Length == 0 || ext is ".x86_64" or ".run" or ".appimage"; + } + private static readonly Encoding FileEncoding = ResolveEncoding(); private static Encoding ResolveEncoding() diff --git a/StarterNG/Classes/Trainset.cs b/StarterNG/Classes/Trainset.cs index 783bc3f..73f19cb 100644 --- a/StarterNG/Classes/Trainset.cs +++ b/StarterNG/Classes/Trainset.cs @@ -395,6 +395,19 @@ public sealed class Coupling Parameters.Insert(0, brake.ToParameter()); } + /// The wheel/damage setting carried in the "W" parameter, or null. + public WheelSettings? GetWheels() => + WheelSettings.FromParameter( + Parameters.FirstOrDefault(p => p.StartsWith("W", StringComparison.Ordinal))); + + /// Replaces (or clears, when empty/null) the "W" wheel parameter. + public void SetWheels(WheelSettings? wheels) + { + Parameters.RemoveAll(p => p.StartsWith("W", StringComparison.Ordinal)); + if (wheels != null && !wheels.IsEmpty) + Parameters.Add(wheels.ToParameter()); + } + public Coupling Clone() => new() { Flags = Flags, @@ -451,4 +464,61 @@ public sealed class BrakeSetting } public string ToParameter() => "B" + Mode + (Load ?? "") + (Switch ?? ""); +} + +/// +/// Wheel-damage settings carried in the "W" parameter of a vehicle's coupling +/// field: W[H<sway%>][F<flat mm>][R<random flat mm>][P<flat prob%>] +/// (e.g. "WH25F5R10P8"). See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow +/// +public sealed class WheelSettings +{ + /// H — sway ("wężykowanie") probability, %. + public int Sway; + + /// F — flat spot ("podkucie") of this size, mm. + public int Flatness; + + /// R — additional random flat spot, 0..x mm. + public int FlatnessRand; + + /// P — flat-spot probability, % (guaranteed when omitted). + public int FlatnessProb; + + public bool IsEmpty => Sway <= 0 && Flatness <= 0 && FlatnessRand <= 0 && FlatnessProb <= 0; + + public static WheelSettings? FromParameter(string? param) + { + if (string.IsNullOrEmpty(param) || param![0] != 'W') + return null; + + var w = new WheelSettings(); + string body = param.Substring(1); + int i = 0; + while (i < body.Length) + { + char code = body[i++]; + int start = i; + while (i < body.Length && char.IsDigit(body[i])) i++; + if (!int.TryParse(body[start..i], out int val)) continue; + switch (char.ToUpperInvariant(code)) + { + case 'H': w.Sway = val; break; + case 'F': w.Flatness = val; break; + case 'R': w.FlatnessRand = val; break; + case 'P': w.FlatnessProb = val; break; + } + } + return w; + } + + public string ToParameter() + { + var sb = new StringBuilder("W"); + if (Sway > 0) sb.Append('H').Append(Sway); + if (Flatness > 0) sb.Append('F').Append(Flatness); + if (FlatnessRand > 0) sb.Append('R').Append(FlatnessRand); + if (FlatnessProb > 0) sb.Append('P').Append(FlatnessProb); + return sb.ToString(); + } } \ No newline at end of file diff --git a/StarterNG/MainWindow.axaml.cs b/StarterNG/MainWindow.axaml.cs index 9844043..7e92cfe 100644 --- a/StarterNG/MainWindow.axaml.cs +++ b/StarterNG/MainWindow.axaml.cs @@ -122,14 +122,19 @@ public partial class MainWindow : Window Settings.Instance.CaptureAndSave(); // launch the game: -s $.scn -v . Keep the handle so we can - // watch for the simulator exiting. + // watch for the simulator exiting. UseShellExecute = false runs the binary + // directly with its arguments, which works the same on Windows and Linux + // (the executable is resolved to eu07.exe / eu07 via ResolveExecutable). Process? sim; try { - sim = Process.Start(new ProcessStartInfo(Settings.Instance.ExecutablePath) + string exe = Path.GetFullPath(Settings.Instance.ResolveExecutable()); + sim = Process.Start(new ProcessStartInfo { + FileName = exe, Arguments = $"-s {exportName} -v {vehicle}", - UseShellExecute = true + WorkingDirectory = Path.GetDirectoryName(exe) ?? Directory.GetCurrentDirectory(), + UseShellExecute = false }); } catch @@ -162,7 +167,7 @@ public partial class MainWindow : Window { if (sim is null) { - string name = Path.GetFileNameWithoutExtension(Settings.Instance.ExecutablePath); + string name = Path.GetFileNameWithoutExtension(Settings.Instance.ResolveExecutable()); sim = Process.GetProcessesByName(name).FirstOrDefault(); } diff --git a/StarterNG/Services/LocalizationService.cs b/StarterNG/Services/LocalizationService.cs index 7f67e68..72179ec 100644 --- a/StarterNG/Services/LocalizationService.cs +++ b/StarterNG/Services/LocalizationService.cs @@ -23,7 +23,7 @@ public class LocalizationService : INotifyPropertyChanged { /// Folder holding the language files, resolved as starter/lang next to the executable. public static string LangDirectory => - Path.Combine(AppContext.BaseDirectory, "starter", "lang"); + Path.Combine(AppContext.BaseDirectory, "startercfg", "lang"); private Dictionary _strings = new(); diff --git a/StarterNG/StarterNG.csproj b/StarterNG/StarterNG.csproj index 5371485..19c128e 100644 --- a/StarterNG/StarterNG.csproj +++ b/StarterNG/StarterNG.csproj @@ -2,14 +2,15 @@ WinExe true + true true net9.0 enable true app.manifest true - 1.0.0.1 - 1.0.0.1 + 1.1.0.3 + 1.1.0.3 Starter eu07.pl @@ -54,9 +55,9 @@ compiled into the assembly. They are copied verbatim into a starter/lang/ folder next to the executable so they can be edited or extended without a rebuild. XmlReader parsing is AOT-safe, unlike the Avalonia XAML loader. --> - + PreserveNewest - starter\lang\%(Filename)%(Extension) + startercfg\lang\%(Filename)%(Extension) diff --git a/StarterNG/Views/Depot.axaml b/StarterNG/Views/Depot.axaml index d5ce7e2..b294469 100644 --- a/StarterNG/Views/Depot.axaml +++ b/StarterNG/Views/Depot.axaml @@ -86,6 +86,11 @@ + + + + + @@ -93,15 +98,20 @@ - - - - - - + + + + + + + + + + diff --git a/StarterNG/Views/Depot.axaml.cs b/StarterNG/Views/Depot.axaml.cs index bc88f8e..120d05b 100644 --- a/StarterNG/Views/Depot.axaml.cs +++ b/StarterNG/Views/Depot.axaml.cs @@ -118,11 +118,11 @@ public partial class Depot : UserControl // Car-type suffixes (longest first) used only for the consist unit label. private static readonly string[] CarSuffixes = { "sa", "sb", "ra", "rb", "s" }; - // Coupling-bit labels in mask-bit order (bit i = value 1<(); + foreach (var item in _consist) + { + var cars = item.Flipped ? item.Cars.AsEnumerable().Reverse() : item.Cars; + foreach (var c in cars) + flat.Add((c, item.Flipped)); + } + + for (int i = 0; i < flat.Count - 1; i++) + { + var (left, lf) = flat[i]; + var (right, rf) = flat[i + 1]; + + var lp = PhysicsFor(left); + var rp = PhysicsFor(right); + + // GetMaxCoupler: the end facing the neighbour (swapped when flipped) + int leftMax = lp == null ? 3 : (lf ? lp.AllowedFlagA : lp.AllowedFlagB); + int rightMax = rp == null ? 3 : (rf ? rp.AllowedFlagB : rp.AllowedFlagA); + int common = leftMax & rightMax; // CommonCoupler = shared flag bits + + string lct = lp == null ? "" : (lf ? lp.ControlTypeA : lp.ControlTypeB); + string rct = rp == null ? "" : (rf ? rp.ControlTypeB : rp.ControlTypeA); + if ((common & Coupling.ControlMU) != 0 && + !string.Equals(lct, rct, StringComparison.OrdinalIgnoreCase)) + common &= ~Coupling.ControlMU; + + left.Coupling.Flags = left.Coupling.Locked ? -common : common; + } + } + + // Train totals (length / mass / Vmax / load) read from the .fiz files. + private void UpdateTrainStats() + { + if (_consist.Count == 0) + { + trainStats.Text = ""; + return; + } + + double lengthM = 0, massKg = 0, loadKg = 0; + double vmax = double.PositiveInfinity; + + foreach (var item in _consist) + foreach (var c in item.Cars) + { + var p = PhysicsFor(c); + if (p != null) + { + lengthM += p.Length; + massKg += p.Mass; + if (p.VMax > 0) vmax = Math.Min(vmax, p.VMax); + } + if (c.LoadCount > 0 && !string.IsNullOrEmpty(c.LoadType)) + loadKg += (double)c.LoadCount * LoadWeight(c.LoadType); + } + + var inv = System.Globalization.CultureInfo.InvariantCulture; + string v = double.IsInfinity(vmax) ? "—" : $"{vmax.ToString("0", inv)} km/h"; + trainStats.Text = + $"{App.Loc["Length"]}: {lengthM.ToString("0.#", inv)} m · " + + $"{App.Loc["Mass"]}: {(massKg / 1000.0).ToString("0.#", inv)} t · " + + $"Vmax: {v} · " + + $"{App.Loc["Load"]}: {(loadKg / 1000.0).ToString("0.#", inv)} t"; + } + private Control BuildCard(ConsistItem item) { bool selected = ReferenceEquals(_selected, item); @@ -961,12 +1047,12 @@ public partial class Depot : UserControl Margin = new Thickness(0, 0, 0, 2) }); - for (int i = 0; i < CouplingBits.Length; i++) + for (int i = 0; i < CouplingBitKeys.Length; i++) { int bit = 1 << i; var check = new CheckBox { - Content = CouplingBits[i], + Content = App.Loc[CouplingBitKeys[i]], IsChecked = d.Coupling.Has(bit), FontSize = 11 }; @@ -1016,16 +1102,19 @@ public partial class Depot : UserControl { brakesPanel.Children.Clear(); loadsPanel.Children.Clear(); + damagePanel.Children.Clear(); if (_selected is null) { brakesPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"])); loadsPanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"])); + damagePanel.Children.Add(DetailHint(App.Loc["SelectVehicleHint"])); return; } BuildBrakesEditor(_selected); BuildLoadsEditor(_selected); + BuildDamageEditor(_selected); } private static TextBlock DetailHint(string text) => new() @@ -1060,20 +1149,20 @@ public partial class Depot : UserControl { var brake = item.Cars[0].Coupling.GetBrake(); - var modeCombo = new ComboBox { FontSize = 12, MinWidth = 150 }; + var modeCombo = new ComboBox { FontSize = 12, MinWidth = 280 }; AddOption(modeCombo, App.Loc["None"], null); - foreach (var m in BrakeSetting.Modes) AddOption(modeCombo, m, m); + foreach (var m in BrakeSetting.Modes) AddOption(modeCombo, BrakeModeLabel(m), m); modeCombo.SelectedIndex = brake?.Mode is { } mode ? Array.IndexOf(BrakeSetting.Modes, mode) + 1 : 0; string?[] loads = { null, "T", "H", "F", "A" }; - var loadCombo = new ComboBox { FontSize = 12, MinWidth = 150 }; - foreach (var l in loads) AddOption(loadCombo, l ?? App.Loc["None"], l); + var loadCombo = new ComboBox { FontSize = 12, MinWidth = 280 }; + foreach (var l in loads) AddOption(loadCombo, LoadLabel(l), l); loadCombo.SelectedIndex = Math.Max(0, Array.IndexOf(loads, brake?.Load)); string?[] switches = { null, "0", "1", "A" }; - var switchCombo = new ComboBox { FontSize = 12, MinWidth = 150 }; - foreach (var s in switches) AddOption(switchCombo, s ?? App.Loc["None"], s); + var switchCombo = new ComboBox { FontSize = 12, MinWidth = 280 }; + foreach (var s in switches) AddOption(switchCombo, SwitchLabel(s), s); switchCombo.SelectedIndex = Math.Max(0, Array.IndexOf(switches, brake?.Switch)); void Apply() @@ -1103,25 +1192,105 @@ public partial class Depot : UserControl brakesPanel.Children.Add(LabeledRow(App.Loc["BrakeSwitch"], switchCombo)); } + // Descriptive labels (what the option does), per the wiki - not the raw code. + private static string BrakeModeLabel(string code) => code switch + { + "G" => App.Loc["BrakeFreight"], + "P" => App.Loc["BrakePassenger"], + "R" => App.Loc["BrakeExpress"], + "R+Mg" => App.Loc["BrakeExpressMg"], + "Q" => App.Loc["BrakeNoAir"], + "O" => App.Loc["BrakeOff"], + "A" => App.Loc["BrakeAuto"], + _ => code + }; + + private static string LoadLabel(string? code) => code switch + { + "T" => App.Loc["LoadEmpty"], + "H" => App.Loc["LoadMedium"], + "F" => App.Loc["LoadFull"], + "A" => App.Loc["LoadAuto"], + _ => App.Loc["None"] + }; + + private static string SwitchLabel(string? code) => code switch + { + "0" => App.Loc["SwitchOff"], + "1" => App.Loc["SwitchOff10"], + "A" => App.Loc["SwitchOn"], + _ => App.Loc["None"] + }; + + // Wheel-damage editor: sway / flat-spot parameters (the "W" coupling prefix). + private void BuildDamageEditor(ConsistItem item) + { + var w = item.Cars[0].Coupling.GetWheels() ?? new WheelSettings(); + + NumericUpDown Spin(int value, int max) => new() + { + FontSize = 12, Minimum = 0, Maximum = max, Increment = 1, + Value = value, MinWidth = 120, FormatString = "0" + }; + + var sway = Spin(w.Sway, 100); + var flat = Spin(w.Flatness, 100); + var flatRand = Spin(w.FlatnessRand, 100); + var flatProb = Spin(w.FlatnessProb, 100); + + void Apply() + { + var ws = new WheelSettings + { + Sway = (int)(sway.Value ?? 0), + Flatness = (int)(flat.Value ?? 0), + FlatnessRand = (int)(flatRand.Value ?? 0), + FlatnessProb = (int)(flatProb.Value ?? 0) + }; + foreach (var c in item.Cars) + c.Coupling.SetWheels(ws); + } + + sway.ValueChanged += (_, _) => Apply(); + flat.ValueChanged += (_, _) => Apply(); + flatRand.ValueChanged += (_, _) => Apply(); + flatProb.ValueChanged += (_, _) => Apply(); + + damagePanel.Children.Add(UnitTitle(item)); + damagePanel.Children.Add(LabeledRow(App.Loc["DamageSway"], sway)); + damagePanel.Children.Add(LabeledRow(App.Loc["DamageFlatness"], flat)); + damagePanel.Children.Add(LabeledRow(App.Loc["DamageFlatnessRand"], flatRand)); + damagePanel.Children.Add(LabeledRow(App.Loc["DamageFlatnessProb"], flatProb)); + } + // Cargo type + amount, written into each car's node::dynamic trailing params. private void BuildLoadsEditor(ConsistItem item) { var lead = item.Cars[0]; + var phys = PhysicsFor(lead); + + // Prefer the cargo this vehicle actually accepts (.fiz LoadAccepted); + // otherwise suggest everything from data/load_weights.txt. Free text is + // still allowed. + var accepted = phys != null && !string.IsNullOrWhiteSpace(phys.LoadAccepted) + ? phys.LoadAccepted.Split(',', ';').Select(s => s.Trim()).Where(s => s.Length > 0).ToList() + : null; - // Suggest cargo names from data/load_weights.txt (same source the original - // Starter uses), while still allowing a free-typed value. var typeBox = new AutoCompleteBox { FontSize = 12, MinWidth = 200, Text = lead.LoadType ?? "", - ItemsSource = LoadTypes(), + ItemsSource = accepted ?? (IEnumerable)LoadTypes(), FilterMode = AutoCompleteFilterMode.ContainsOrdinal }; + + // Cap the amount at the .fiz MaxLoad when it is defined. + int maxLoad = phys != null && phys.MaxLoad > 0 ? phys.MaxLoad : 1000; var countBox = new NumericUpDown { - FontSize = 12, Minimum = 0, Maximum = 1000, Increment = 1, - Value = lead.LoadCount, MinWidth = 120, FormatString = "0" + FontSize = 12, Minimum = 0, Maximum = maxLoad, Increment = 1, + Value = Math.Min(lead.LoadCount, maxLoad), MinWidth = 120, FormatString = "0" }; void Apply() @@ -1149,16 +1318,19 @@ public partial class Depot : UserControl }); } - // Cargo names parsed once from data/load_weights.txt (the file the simulator - // and the original Starter use), for the load-type suggestions. Each line is - // ": "; only the name (before the colon) is used here. + // Cargo names + per-unit weights parsed once from data/load_weights.txt (the + // file the simulator and the original Starter use). Each line is + // ": ". private static List? _loadTypes; - private static IReadOnlyList LoadTypes() + private static Dictionary? _loadWeights; + + private static void EnsureLoads() { if (_loadTypes != null) - return _loadTypes; + return; - var list = new List(); + var names = new List(); + var weights = new Dictionary(StringComparer.OrdinalIgnoreCase); try { string path = Path.Combine("data", "load_weights.txt"); @@ -1171,17 +1343,32 @@ public partial class Depot : UserControl continue; int colon = line.IndexOf(':'); string name = (colon >= 0 ? line[..colon] : line).Trim(); - if (name.Length > 0) - list.Add(name); + if (name.Length == 0) continue; + names.Add(name); + if (colon >= 0 && int.TryParse(line[(colon + 1)..].Trim(), out int w)) + weights[name] = w; } } } catch { /* missing / unreadable - just no suggestions */ } - _loadTypes = list.Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(s => s, StringComparer.OrdinalIgnoreCase) - .ToList(); - return _loadTypes; + _loadTypes = names.Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase) + .ToList(); + _loadWeights = weights; + } + + private static IReadOnlyList LoadTypes() + { + EnsureLoads(); + return _loadTypes!; + } + + // Per-unit weight (kg) of a cargo, defaulting to 1000 like the original Starter. + private static int LoadWeight(string? name) + { + EnsureLoads(); + return !string.IsNullOrEmpty(name) && _loadWeights!.TryGetValue(name!, out int w) ? w : 1000; } // ------------------------------------------------------------------- minis diff --git a/StarterNG/starter/lang/en.xml b/StarterNG/startercfg/lang/en.xml similarity index 87% rename from StarterNG/starter/lang/en.xml rename to StarterNG/startercfg/lang/en.xml index f13304c..743fb20 100644 --- a/StarterNG/starter/lang/en.xml +++ b/StarterNG/startercfg/lang/en.xml @@ -121,11 +121,51 @@ Load setting Brake switch + + + Mechanical + Pneumatic 5 atm (brakes) + Multiple-unit control + High voltage + Gangway between vehicles + Auxiliary pneumatic 8 atm + Heating + Workshop coupling (uncouple lock) + + + Freight (G) + Passenger (P) + Express (R) + Express + magnetic rail (R+Mg) + Start without air (Q) + Brakes disconnected (O) + Automatic (A) + + + Empty (T) + Loaded I — medium (H) + Loaded II (F) + Automatic (A) + + + Off (0) + Off with 10% chance (1) + On (A) + + + Damage + Sway — probability (%) + Flat spot (mm) + Random flat spot (0–x mm) + Flat-spot probability (%) Load type Amount Set an amount above 0 to load the vehicle. Wagon number Set + Length + Mass + Load Select a vehicle in the consist to edit it. Editing options coming soon. Split diff --git a/StarterNG/starter/lang/pl.xml b/StarterNG/startercfg/lang/pl.xml similarity index 86% rename from StarterNG/starter/lang/pl.xml rename to StarterNG/startercfg/lang/pl.xml index 5224df0..6672adc 100644 --- a/StarterNG/starter/lang/pl.xml +++ b/StarterNG/startercfg/lang/pl.xml @@ -121,11 +121,51 @@ Nastawa ładunkowa Włącznik hamulca + + + Mechaniczny + Pneumatyczny 5 atm (hamulce) + Ukrotnienie (sterowanie wielokrotne) + Wysokie napięcie + Przejście między pojazdami + Pneumatyczny pomocniczy 8 atm + Ogrzewanie + Sprzęg warsztatowy (blokada rozłączania) + + + Towarowa (G) + Osobowa (P) + Pospieszna (R) + Pospieszna + szynowy magnetyczny (R+Mg) + Start bez powietrza (Q) + Odłączone hamulce (O) + Wybór automatyczny (A) + + + Próżny (T) + Ładowny I — średni (H) + Ładowny II (F) + Wybór automatyczny (A) + + + Wyłączony (0) + Wyłączony z prawd. 10% (1) + Włączony (A) + + + Uszkodzenia + Wężykowanie — prawdop. (%) + Podkucie (mm) + Podkucie losowe (0–x mm) + Prawdop. podkucia (%) Rodzaj ładunku Ilość Ustaw ilość większą od 0, aby załadować pojazd. Numer wagonu Ustaw + Długość + Masa + Ładunek Wybierz pojazd w składzie, aby go edytować. Opcje edycji wkrótce. Rozdziel diff --git a/linuix.run/StarterNG (linux64).run.xml b/linuix.run/StarterNG (linux64).run.xml new file mode 100644 index 0000000..ebe9f91 --- /dev/null +++ b/linuix.run/StarterNG (linux64).run.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/windows.run/StarterNG (win64).run.xml b/windows.run/StarterNG (win64).run.xml new file mode 100644 index 0000000..541d912 --- /dev/null +++ b/windows.run/StarterNG (win64).run.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file