mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-06 05:38:35 +01:00
Strip all comments from the C# sources
Removes every // and /// comment from the 28 .cs files, -1331 lines.
Done with a lexer rather than a regex: the sources carry plenty of // inside
string literals (@"//\$decor\b", the //$o trainset directives, sb.Append("// "),
"https://wiki.eu07.pl/") and those must survive. It tracks regular, verbatim and
interpolated strings, interpolation holes and char literals. Every file was
checked by comparing the sequence of string-literal characters before and after
the strip; all 28 match. BOMs preserved, blank-line runs collapsed to one.
Claude-Session: https://claude.ai/code/session_01HamVNWE9mwmz1vs2iEpNQq
This commit is contained in:
@@ -15,18 +15,15 @@ namespace StarterNG;
|
|||||||
|
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
|
|
||||||
public static LocalizationService Loc { get; } = new();
|
public static LocalizationService Loc { get; } = new();
|
||||||
|
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
// Load the persisted configuration up front so the saved language wins;
|
|
||||||
// fall back to the system culture only when the file has no lang entry.
|
|
||||||
Settings.Instance.Load();
|
Settings.Instance.Load();
|
||||||
|
|
||||||
// Key bindings live in eu07_input-keyboard.ini next to eu07.ini.
|
|
||||||
KeyboardConfig.Instance.Load();
|
KeyboardConfig.Instance.Load();
|
||||||
|
|
||||||
CultureInfo ci = CultureInfo.InstalledUICulture ;
|
CultureInfo ci = CultureInfo.InstalledUICulture ;
|
||||||
@@ -43,17 +40,9 @@ public partial class App : Application
|
|||||||
ApplyLanguage(langName);
|
ApplyLanguage(langName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Swaps the active language by loading the matching XML file from the
|
|
||||||
/// <c>lang/</c> folder next to the executable (see <see cref="LocalizationService"/>).
|
|
||||||
/// The translations are no longer compiled into the assembly, so they can be
|
|
||||||
/// edited or added without rebuilding the launcher. Accepts either a display
|
|
||||||
/// name ("English", "Polski") or a short code ("en", "pl").
|
|
||||||
/// </summary>
|
|
||||||
public static void ApplyLanguage(string langNameOrCode)
|
public static void ApplyLanguage(string langNameOrCode)
|
||||||
{
|
{
|
||||||
// Loc.Load resolves the file and returns the canonical display name,
|
|
||||||
// which we mirror back into the settings so the stored value stays valid.
|
|
||||||
var resolvedName = Loc.Load(langNameOrCode);
|
var resolvedName = Loc.Load(langNameOrCode);
|
||||||
Settings.Instance.Language = resolvedName;
|
Settings.Instance.Language = resolvedName;
|
||||||
}
|
}
|
||||||
@@ -62,11 +51,9 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
// Code page 1250 is needed to parse scenery files (done during load).
|
|
||||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
|
|
||||||
// Persist settings when the launcher is closing. Key bindings are only
|
|
||||||
// rewritten when actually edited, so an untouched file keeps its layout.
|
|
||||||
desktop.ShutdownRequested += (_, _) =>
|
desktop.ShutdownRequested += (_, _) =>
|
||||||
{
|
{
|
||||||
Settings.Instance.CaptureAndSave();
|
Settings.Instance.CaptureAndSave();
|
||||||
@@ -84,7 +71,7 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
// Show splash only if loading takes more than 300ms
|
|
||||||
if (sw.ElapsedMilliseconds > 300 && splash == null)
|
if (sw.ElapsedMilliseconds > 300 && splash == null)
|
||||||
{
|
{
|
||||||
splash = new SplashWindow();
|
splash = new SplashWindow();
|
||||||
@@ -110,4 +97,4 @@ public partial class App : Application
|
|||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,9 @@ using System.Reflection;
|
|||||||
|
|
||||||
namespace StarterNG;
|
namespace StarterNG;
|
||||||
|
|
||||||
/// <summary>Static, display-ready information about the running launcher build.</summary>
|
|
||||||
public static class AppInfo
|
public static class AppInfo
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// The launcher's assembly version (e.g. "1.2.3.4"), with a " (DEBUG)" suffix
|
|
||||||
/// appended for Debug builds so debug and release binaries are easy to tell
|
|
||||||
/// apart. Computed once; safe to bind with {x:Static}.
|
|
||||||
/// </summary>
|
|
||||||
public static string Version { get; } = BuildVersion();
|
public static string Version { get; } = BuildVersion();
|
||||||
|
|
||||||
private static string BuildVersion()
|
private static string BuildVersion()
|
||||||
|
|||||||
@@ -2,12 +2,6 @@
|
|||||||
|
|
||||||
namespace StarterNG.Classes;
|
namespace StarterNG.Classes;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Shared selection state across views: the scenery and trainset currently in
|
|
||||||
/// focus. The depot edits this trainset in place, so changes made in the depot
|
|
||||||
/// are reflected on the scenery (both views share the same Scenery/Trainset
|
|
||||||
/// objects from <see cref="GameData"/>).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AppState
|
public sealed class AppState
|
||||||
{
|
{
|
||||||
public static AppState Instance { get; } = new();
|
public static AppState Instance { get; } = new();
|
||||||
@@ -40,11 +34,6 @@ public sealed class AppState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Node name of the vehicle the player starts in (<c>-v</c>), like Pascal
|
|
||||||
/// <c>SelVehicle</c>. Updated when a consist car is clicked in the depot or a
|
|
||||||
/// trainset is picked in Scenarios.
|
|
||||||
/// </summary>
|
|
||||||
public string? StartingVehicleName
|
public string? StartingVehicleName
|
||||||
{
|
{
|
||||||
get => _startingVehicleName;
|
get => _startingVehicleName;
|
||||||
@@ -56,6 +45,5 @@ public sealed class AppState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Notify listeners after in-place edits that affect Start enablement.</summary>
|
|
||||||
public void NotifyChanged() => Changed?.Invoke();
|
public void NotifyChanged() => Changed?.Invoke();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,35 +5,17 @@ using System.Text;
|
|||||||
|
|
||||||
namespace StarterNG.Classes;
|
namespace StarterNG.Classes;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A line-oriented reader/writer for the EU07 <c>eu07.ini</c> configuration
|
|
||||||
/// file.
|
|
||||||
///
|
|
||||||
/// The file is not a classic INI: it is a flat list of <c>key value [value…]</c>
|
|
||||||
/// entries, one per line, with <c>//</c> comments (either trailing a line or
|
|
||||||
/// commenting the whole line out). The simulator parses it as a stream of
|
|
||||||
/// whitespace-separated tokens.
|
|
||||||
///
|
|
||||||
/// This class keeps every line verbatim and only rewrites the value of a key
|
|
||||||
/// when <see cref="Set"/> is called. Comments, blank lines, commented-out
|
|
||||||
/// entries and — crucially — any keys the launcher does not understand are
|
|
||||||
/// preserved on save, so editing a handful of settings never drops the rest of
|
|
||||||
/// the user's configuration.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ConfigFile
|
public sealed class ConfigFile
|
||||||
{
|
{
|
||||||
private readonly List<string> _lines = new();
|
private readonly List<string> _lines = new();
|
||||||
|
|
||||||
// Active (uncommented) setting key -> index into _lines. Last occurrence wins,
|
|
||||||
// matching the simulator which lets later entries override earlier ones.
|
|
||||||
private readonly Dictionary<string, int> _index =
|
private readonly Dictionary<string, int> _index =
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
/// <summary>Parses raw file text, preserving line breaks and content.</summary>
|
|
||||||
public static ConfigFile Parse(string text)
|
public static ConfigFile Parse(string text)
|
||||||
{
|
{
|
||||||
var cfg = new ConfigFile();
|
var cfg = new ConfigFile();
|
||||||
// Normalise CRLF/CR to LF for splitting; output uses Environment.NewLine.
|
|
||||||
text = text.Replace("\r\n", "\n").Replace('\r', '\n');
|
text = text.Replace("\r\n", "\n").Replace('\r', '\n');
|
||||||
foreach (var line in text.Split('\n'))
|
foreach (var line in text.Split('\n'))
|
||||||
cfg.AddLine(line);
|
cfg.AddLine(line);
|
||||||
@@ -45,13 +27,11 @@ public sealed class ConfigFile
|
|||||||
int idx = _lines.Count;
|
int idx = _lines.Count;
|
||||||
_lines.Add(raw);
|
_lines.Add(raw);
|
||||||
if (TryParseKey(raw, out string key))
|
if (TryParseKey(raw, out string key))
|
||||||
_index[key] = idx; // later duplicates override earlier ones
|
_index[key] = idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>True if the key is present as an active (uncommented) entry.</summary>
|
|
||||||
public bool Has(string key) => _index.ContainsKey(key);
|
public bool Has(string key) => _index.ContainsKey(key);
|
||||||
|
|
||||||
/// <summary>Raw value (all tokens after the key, comment stripped), or null.</summary>
|
|
||||||
public string? GetRaw(string key)
|
public string? GetRaw(string key)
|
||||||
{
|
{
|
||||||
if (!_index.TryGetValue(key, out int i))
|
if (!_index.TryGetValue(key, out int i))
|
||||||
@@ -89,7 +69,6 @@ public sealed class ConfigFile
|
|||||||
CultureInfo.InvariantCulture, out double d) ? d : fallback;
|
CultureInfo.InvariantCulture, out double d) ? d : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>All whitespace-separated value tokens for a key (never null).</summary>
|
|
||||||
public string[] GetTokens(string key)
|
public string[] GetTokens(string key)
|
||||||
{
|
{
|
||||||
var v = GetRaw(key);
|
var v = GetRaw(key);
|
||||||
@@ -98,11 +77,6 @@ public sealed class ConfigFile
|
|||||||
: v.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
: v.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sets the value of <paramref name="key"/>. If the key already exists its
|
|
||||||
/// line is rewritten in place, preserving original indentation and any
|
|
||||||
/// trailing comment. Otherwise a new line is appended.
|
|
||||||
/// </summary>
|
|
||||||
public void Set(string key, string value)
|
public void Set(string key, string value)
|
||||||
{
|
{
|
||||||
value ??= string.Empty;
|
value ??= string.Empty;
|
||||||
@@ -125,11 +99,6 @@ public sealed class ConfigFile
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deactivates <paramref name="key"/> by turning its line into a comment,
|
|
||||||
/// preserving indentation, the key/value that was there and any trailing
|
|
||||||
/// comment. No-op if the key is not currently active.
|
|
||||||
/// </summary>
|
|
||||||
public void Remove(string key)
|
public void Remove(string key)
|
||||||
{
|
{
|
||||||
if (!_index.TryGetValue(key, out int i))
|
if (!_index.TryGetValue(key, out int i))
|
||||||
@@ -153,28 +122,19 @@ public sealed class ConfigFile
|
|||||||
public void SetDouble(string key, double value) =>
|
public void SetDouble(string key, double value) =>
|
||||||
Set(key, value.ToString("0.###", CultureInfo.InvariantCulture));
|
Set(key, value.ToString("0.###", CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
/// <summary>Serialises back to text using the platform newline.</summary>
|
|
||||||
public string ToText() => string.Join(Environment.NewLine, _lines);
|
public string ToText() => string.Join(Environment.NewLine, _lines);
|
||||||
|
|
||||||
// --- line parsing helpers -------------------------------------------------
|
|
||||||
|
|
||||||
private static bool TryParseKey(string raw, out string key)
|
private static bool TryParseKey(string raw, out string key)
|
||||||
{
|
{
|
||||||
SplitLine(raw, out _, out key, out _, out _);
|
SplitLine(raw, out _, out key, out _, out _);
|
||||||
return key.Length > 0;
|
return key.Length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Decomposes a raw line into leading whitespace, key, value (tokens after
|
|
||||||
/// the key) and trailing comment (including the <c>//</c>). For blank or
|
|
||||||
/// fully commented-out lines, key is empty.
|
|
||||||
/// </summary>
|
|
||||||
private static void SplitLine(string raw, out string indent, out string key,
|
private static void SplitLine(string raw, out string indent, out string key,
|
||||||
out string value, out string comment)
|
out string value, out string comment)
|
||||||
{
|
{
|
||||||
indent = key = value = comment = string.Empty;
|
indent = key = value = comment = string.Empty;
|
||||||
|
|
||||||
// leading whitespace
|
|
||||||
int start = 0;
|
int start = 0;
|
||||||
while (start < raw.Length && char.IsWhiteSpace(raw[start]))
|
while (start < raw.Length && char.IsWhiteSpace(raw[start]))
|
||||||
start++;
|
start++;
|
||||||
@@ -182,7 +142,6 @@ public sealed class ConfigFile
|
|||||||
|
|
||||||
string body = raw.Substring(start);
|
string body = raw.Substring(start);
|
||||||
|
|
||||||
// A line whose first non-space content is "//" is a pure comment.
|
|
||||||
if (body.StartsWith("//"))
|
if (body.StartsWith("//"))
|
||||||
{
|
{
|
||||||
comment = body;
|
comment = body;
|
||||||
@@ -191,8 +150,6 @@ public sealed class ConfigFile
|
|||||||
if (body.Length == 0)
|
if (body.Length == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Split off a trailing comment: the first "//" preceded by whitespace
|
|
||||||
// (so URLs such as tcp://… inside a value are not mistaken for one).
|
|
||||||
int commentAt = FindCommentStart(body);
|
int commentAt = FindCommentStart(body);
|
||||||
string content = commentAt >= 0 ? body.Substring(0, commentAt) : body;
|
string content = commentAt >= 0 ? body.Substring(0, commentAt) : body;
|
||||||
if (commentAt >= 0)
|
if (commentAt >= 0)
|
||||||
|
|||||||
@@ -13,14 +13,8 @@ public enum LoadPhase
|
|||||||
Done
|
Done
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A single progress update emitted while the game data loads.</summary>
|
|
||||||
public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail);
|
public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Loads and holds the data shared across the launcher (vehicle database and
|
|
||||||
/// parsed sceneries) so it is parsed once, at startup, behind the splash —
|
|
||||||
/// instead of inside each view's constructor.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GameData
|
public sealed class GameData
|
||||||
{
|
{
|
||||||
public static GameData Instance { get; } = new();
|
public static GameData Instance { get; } = new();
|
||||||
@@ -30,10 +24,6 @@ public sealed class GameData
|
|||||||
|
|
||||||
public bool Loaded { get; private set; }
|
public bool Loaded { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses the vehicle database and all sceneries, reporting progress.
|
|
||||||
/// Runs synchronously; call it from a background thread. Never throws.
|
|
||||||
/// </summary>
|
|
||||||
public void Load(IProgress<LoadStatus>? progress = null,
|
public void Load(IProgress<LoadStatus>? progress = null,
|
||||||
string vehiclesDir = "databases/vehicles/",
|
string vehiclesDir = "databases/vehicles/",
|
||||||
string sceneryDir = "scenery/",
|
string sceneryDir = "scenery/",
|
||||||
@@ -47,27 +37,21 @@ public sealed class GameData
|
|||||||
int total = Math.Max(1, vehicleFiles.Count + scnFiles.Count);
|
int total = Math.Max(1, vehicleFiles.Count + scnFiles.Count);
|
||||||
int done = 0;
|
int done = 0;
|
||||||
|
|
||||||
// Phase 1 - vehicle database
|
|
||||||
Vehicles.BeginLoad();
|
Vehicles.BeginLoad();
|
||||||
foreach (string file in vehicleFiles)
|
foreach (string file in vehicleFiles)
|
||||||
{
|
{
|
||||||
progress?.Report(new LoadStatus((double)done / total, LoadPhase.Vehicles,
|
progress?.Report(new LoadStatus((double)done / total, LoadPhase.Vehicles,
|
||||||
Path.GetFileName(file)));
|
Path.GetFileName(file)));
|
||||||
try { Vehicles.LoadFile(file); }
|
try { Vehicles.LoadFile(file); }
|
||||||
catch { /* skip malformed file */ }
|
catch { }
|
||||||
done++;
|
done++;
|
||||||
}
|
}
|
||||||
Vehicles.EndLoad();
|
Vehicles.EndLoad();
|
||||||
|
|
||||||
// Preload the miniature .bmp index now (behind the splash) so the depot's
|
|
||||||
// first thumbnail render doesn't scan textures/mini/ on the UI thread.
|
|
||||||
VehicleDatabase.PreloadMiniIndex(miniDir);
|
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();
|
Physics.PreloadIndex();
|
||||||
|
|
||||||
// Phase 2 - sceneries (parallelized)
|
|
||||||
var sceneryLock = new object();
|
var sceneryLock = new object();
|
||||||
var doneCount = done;
|
var doneCount = done;
|
||||||
Parallel.ForEach(scnFiles, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
|
Parallel.ForEach(scnFiles, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
|
||||||
@@ -84,7 +68,7 @@ public sealed class GameData
|
|||||||
Path.GetFileName(file)));
|
Path.GetFileName(file)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* skip unreadable scenery */ }
|
catch { }
|
||||||
});
|
});
|
||||||
done = doneCount;
|
done = doneCount;
|
||||||
|
|
||||||
@@ -92,7 +76,6 @@ public sealed class GameData
|
|||||||
Loaded = true;
|
Loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Re-reads scenery/*.scn (Pascal actReloadScenarios).</summary>
|
|
||||||
public void ReloadSceneries(string sceneryDir = "scenery/")
|
public void ReloadSceneries(string sceneryDir = "scenery/")
|
||||||
{
|
{
|
||||||
Sceneries.Clear();
|
Sceneries.Clear();
|
||||||
@@ -107,7 +90,7 @@ public sealed class GameData
|
|||||||
lock (sceneryLock)
|
lock (sceneryLock)
|
||||||
Sceneries.Add(scenery);
|
Sceneries.Add(scenery);
|
||||||
}
|
}
|
||||||
catch { /* skip */ }
|
catch { }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,11 @@ using Avalonia.Input;
|
|||||||
|
|
||||||
namespace StarterNG.Classes;
|
namespace StarterNG.Classes;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One cap on the on-screen keyboard. <see cref="Token"/> is the simulator key
|
|
||||||
/// token a binding would reference; when null the cap is a non-bindable filler
|
|
||||||
/// (Shift, Ctrl, Tab …) drawn only to keep the layout recognisable.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class KeyCap
|
public sealed class KeyCap
|
||||||
{
|
{
|
||||||
public string Label = string.Empty;
|
public string Label = string.Empty;
|
||||||
public string? Token; // null => filler / not bindable
|
public string? Token;
|
||||||
public double Width = 1.0; // relative width (1 == a standard key)
|
public double Width = 1.0;
|
||||||
|
|
||||||
public KeyCap(string label, string? token = null, double width = 1.0)
|
public KeyCap(string label, string? token = null, double width = 1.0)
|
||||||
{
|
{
|
||||||
@@ -23,20 +18,12 @@ public sealed class KeyCap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Translates between physical keyboard input, simulator key tokens and the
|
|
||||||
/// on-screen keyboard layout used by the controls visualisation.
|
|
||||||
/// </summary>
|
|
||||||
public static class KeyMap
|
public static class KeyMap
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Resolves a pressed key to its simulator token, using the physical key to
|
|
||||||
/// disambiguate the numeric keypad. Returns null for keys with no token
|
|
||||||
/// (e.g. bare modifiers).
|
|
||||||
/// </summary>
|
|
||||||
public static string? FromInput(Key key, PhysicalKey physical)
|
public static string? FromInput(Key key, PhysicalKey physical)
|
||||||
{
|
{
|
||||||
// Numeric keypad first - distinguished only by the physical key.
|
|
||||||
switch (physical)
|
switch (physical)
|
||||||
{
|
{
|
||||||
case PhysicalKey.NumPad0: return "num_0";
|
case PhysicalKey.NumPad0: return "num_0";
|
||||||
@@ -57,15 +44,12 @@ public static class KeyMap
|
|||||||
case PhysicalKey.NumPadEnter: return "num_enter";
|
case PhysicalKey.NumPadEnter: return "num_enter";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Numeric keypad via the virtual key (fallback when PhysicalKey is Unknown).
|
|
||||||
if (key >= Key.NumPad0 && key <= Key.NumPad9)
|
if (key >= Key.NumPad0 && key <= Key.NumPad9)
|
||||||
return "num_" + (char)('0' + (key - Key.NumPad0));
|
return "num_" + (char)('0' + (key - Key.NumPad0));
|
||||||
|
|
||||||
// Letters.
|
|
||||||
if (key >= Key.A && key <= Key.Z)
|
if (key >= Key.A && key <= Key.Z)
|
||||||
return ((char)('a' + (key - Key.A))).ToString();
|
return ((char)('a' + (key - Key.A))).ToString();
|
||||||
|
|
||||||
// Top-row digits.
|
|
||||||
if (key >= Key.D0 && key <= Key.D9)
|
if (key >= Key.D0 && key <= Key.D9)
|
||||||
return ((char)('0' + (key - Key.D0))).ToString();
|
return ((char)('0' + (key - Key.D0))).ToString();
|
||||||
|
|
||||||
@@ -98,14 +82,12 @@ public static class KeyMap
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>True for keys that only act as modifiers and never form a binding alone.</summary>
|
|
||||||
public static bool IsModifierKey(Key key) => key is
|
public static bool IsModifierKey(Key key) => key is
|
||||||
Key.LeftShift or Key.RightShift or
|
Key.LeftShift or Key.RightShift or
|
||||||
Key.LeftCtrl or Key.RightCtrl or
|
Key.LeftCtrl or Key.RightCtrl or
|
||||||
Key.LeftAlt or Key.RightAlt or
|
Key.LeftAlt or Key.RightAlt or
|
||||||
Key.LWin or Key.RWin;
|
Key.LWin or Key.RWin;
|
||||||
|
|
||||||
/// <summary>Friendly, explicit name for a token, used in the binding list text.</summary>
|
|
||||||
public static string DisplayName(string? token)
|
public static string DisplayName(string? token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(token) ||
|
if (string.IsNullOrEmpty(token) ||
|
||||||
@@ -144,14 +126,9 @@ public static class KeyMap
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── On-screen keyboard layout ──────────────────────────────────────────
|
|
||||||
// Filler caps (Token == null) keep the shape familiar; only caps with a
|
|
||||||
// token are coloured by assignment state.
|
|
||||||
|
|
||||||
/// <summary>Rows of the main (alphanumeric) keyboard block.</summary>
|
|
||||||
public static readonly KeyCap[][] MainBlock =
|
public static readonly KeyCap[][] MainBlock =
|
||||||
{
|
{
|
||||||
// F-row keys are reserved by the simulator, so they carry no token.
|
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
new KeyCap("Esc", null, 1.4),
|
new KeyCap("Esc", null, 1.4),
|
||||||
@@ -205,7 +182,6 @@ public static class KeyMap
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Rows of the numeric-keypad block, drawn to the right of the main block.</summary>
|
|
||||||
public static readonly KeyCap[][] NumpadBlock =
|
public static readonly KeyCap[][] NumpadBlock =
|
||||||
{
|
{
|
||||||
new[]
|
new[]
|
||||||
@@ -234,10 +210,9 @@ public static class KeyMap
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Navigation cluster (Insert/Home/PageUp · Delete/End/PageDown), drawn between the blocks.</summary>
|
|
||||||
public static readonly KeyCap[][] NavBlock =
|
public static readonly KeyCap[][] NavBlock =
|
||||||
{
|
{
|
||||||
// PgUp / PgDn are reserved by the simulator (no token), like [ ] and Enter.
|
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp"),
|
new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp"),
|
||||||
|
|||||||
@@ -5,60 +5,33 @@ using System.Text;
|
|||||||
|
|
||||||
namespace StarterNG.Classes;
|
namespace StarterNG.Classes;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A single keyboard command binding parsed from <c>eu07_input-keyboard.ini</c>.
|
|
||||||
/// A binding is a simulator command name, an optional pair of modifier flags
|
|
||||||
/// (<c>shift</c> / <c>ctrl</c>, either or both) and the key it is mapped to.
|
|
||||||
/// <c>none</c> (or an empty key) means the command is intentionally unbound.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class KeyBinding
|
public sealed class KeyBinding
|
||||||
{
|
{
|
||||||
/// <summary>Simulator command token, e.g. <c>mastercontrollerincrease</c>.</summary>
|
|
||||||
public string Command = string.Empty;
|
public string Command = string.Empty;
|
||||||
|
|
||||||
/// <summary>True when the binding requires the Shift modifier.</summary>
|
|
||||||
public bool Shift;
|
public bool Shift;
|
||||||
|
|
||||||
/// <summary>True when the binding requires the Ctrl modifier.</summary>
|
|
||||||
public bool Ctrl;
|
public bool Ctrl;
|
||||||
|
|
||||||
/// <summary>The key token (e.g. <c>q</c>, <c>num_+</c>) or <c>none</c> when unbound.</summary>
|
|
||||||
public string Key = "none";
|
public string Key = "none";
|
||||||
|
|
||||||
/// <summary>Human description taken from the line's trailing comment (no <c>//</c>).</summary>
|
|
||||||
public string Description = string.Empty;
|
public string Description = string.Empty;
|
||||||
|
|
||||||
/// <summary>Index into the backing line list, so the original line can be rewritten in place.</summary>
|
|
||||||
internal int LineIndex = -1;
|
internal int LineIndex = -1;
|
||||||
|
|
||||||
/// <summary>True when the command is mapped to an actual key.</summary>
|
|
||||||
public bool IsAssigned =>
|
public bool IsAssigned =>
|
||||||
!string.IsNullOrEmpty(Key) &&
|
!string.IsNullOrEmpty(Key) &&
|
||||||
!Key.Equals("none", StringComparison.OrdinalIgnoreCase);
|
!Key.Equals("none", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reader/writer for the simulator's keyboard layout file,
|
|
||||||
/// <c>eu07_input-keyboard.ini</c>, which lives next to <c>eu07.ini</c>
|
|
||||||
/// (<c>%APPDATA%\MaSzyna\</c> on Windows, <c>~/.config/MaSzyna/</c> elsewhere).
|
|
||||||
///
|
|
||||||
/// <para>The file is a flat list of <c>command [shift] [ctrl] key // comment</c>
|
|
||||||
/// lines. Modifiers are the literal tokens <c>shift</c> and <c>ctrl</c> appearing
|
|
||||||
/// in any order between the command and the key; the last value token is the key.
|
|
||||||
/// Blank lines, pure comments and any lines the launcher does not recognise are
|
|
||||||
/// preserved verbatim on save - only the binding lines the user actually edits
|
|
||||||
/// are rewritten.</para>
|
|
||||||
/// </summary>
|
|
||||||
public sealed class KeyboardConfig
|
public sealed class KeyboardConfig
|
||||||
{
|
{
|
||||||
public static KeyboardConfig Instance { get; } = new();
|
public static KeyboardConfig Instance { get; } = new();
|
||||||
|
|
||||||
private readonly List<string> _lines = new();
|
private readonly List<string> _lines = new();
|
||||||
|
|
||||||
/// <summary>All recognised bindings, in file order.</summary>
|
|
||||||
public List<KeyBinding> Bindings { get; private set; } = new();
|
public List<KeyBinding> Bindings { get; private set; } = new();
|
||||||
|
|
||||||
/// <summary>True when a binding has been changed since the last load/save.</summary>
|
|
||||||
public bool Dirty { get; set; }
|
public bool Dirty { get; set; }
|
||||||
|
|
||||||
private string _savePath = string.Empty;
|
private string _savePath = string.Empty;
|
||||||
@@ -67,7 +40,7 @@ public sealed class KeyboardConfig
|
|||||||
|
|
||||||
private static Encoding ResolveEncoding()
|
private static Encoding ResolveEncoding()
|
||||||
{
|
{
|
||||||
// Comments use Polish diacritics in Windows-1250, like eu07.ini; preserve them.
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
@@ -79,7 +52,6 @@ public sealed class KeyboardConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Per-user keyboard config path for the current OS (next to eu07.ini).</summary>
|
|
||||||
public static string UserConfigPath()
|
public static string UserConfigPath()
|
||||||
{
|
{
|
||||||
if (OperatingSystem.IsWindows())
|
if (OperatingSystem.IsWindows())
|
||||||
@@ -103,15 +75,12 @@ public sealed class KeyboardConfig
|
|||||||
return Path.Combine(AppContext.BaseDirectory, "eu07_input-keyboard.ini");
|
return Path.Combine(AppContext.BaseDirectory, "eu07_input-keyboard.ini");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Default file shipped in the simulator's working directory.</summary>
|
|
||||||
private static string WorkingDirDefaultPath() =>
|
private static string WorkingDirDefaultPath() =>
|
||||||
Path.Combine(Directory.GetCurrentDirectory(), "eu07_input-keyboard.ini");
|
Path.Combine(Directory.GetCurrentDirectory(), "eu07_input-keyboard.ini");
|
||||||
|
|
||||||
/// <summary>Fallback template bundled with the launcher (startercfg/).</summary>
|
|
||||||
private static string BundledDefaultPath() =>
|
private static string BundledDefaultPath() =>
|
||||||
Path.Combine(AppContext.BaseDirectory, "startercfg", "eu07_input-keyboard.ini");
|
Path.Combine(AppContext.BaseDirectory, "startercfg", "eu07_input-keyboard.ini");
|
||||||
|
|
||||||
/// <summary>Loads the per-user file, falling back to the working-dir or bundled default.</summary>
|
|
||||||
public void Load()
|
public void Load()
|
||||||
{
|
{
|
||||||
_savePath = UserConfigPath();
|
_savePath = UserConfigPath();
|
||||||
@@ -120,7 +89,6 @@ public sealed class KeyboardConfig
|
|||||||
Dirty = false;
|
Dirty = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Reloads the shipped defaults, discarding the user's current edits.</summary>
|
|
||||||
public void LoadDefaults()
|
public void LoadDefaults()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_savePath))
|
if (string.IsNullOrEmpty(_savePath))
|
||||||
@@ -151,7 +119,6 @@ public sealed class KeyboardConfig
|
|||||||
return paths.Length > 0 ? paths[^1] : string.Empty;
|
return paths.Length > 0 ? paths[^1] : string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Parses raw file text into <see cref="Bindings"/>, keeping every line verbatim.</summary>
|
|
||||||
public void Parse(string text)
|
public void Parse(string text)
|
||||||
{
|
{
|
||||||
_lines.Clear();
|
_lines.Clear();
|
||||||
@@ -170,10 +137,6 @@ public sealed class KeyboardConfig
|
|||||||
Bindings = bindings;
|
Bindings = bindings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses a single line into a binding. Returns false for blank lines, pure
|
|
||||||
/// comments and anything without a command token.
|
|
||||||
/// </summary>
|
|
||||||
private static bool TryParseBinding(string raw, int lineIndex, out KeyBinding binding)
|
private static bool TryParseBinding(string raw, int lineIndex, out KeyBinding binding)
|
||||||
{
|
{
|
||||||
binding = new KeyBinding { LineIndex = lineIndex };
|
binding = new KeyBinding { LineIndex = lineIndex };
|
||||||
@@ -181,7 +144,6 @@ public sealed class KeyboardConfig
|
|||||||
string body = raw;
|
string body = raw;
|
||||||
string description = string.Empty;
|
string description = string.Empty;
|
||||||
|
|
||||||
// Split off a trailing "// comment".
|
|
||||||
int commentAt = body.IndexOf("//", StringComparison.Ordinal);
|
int commentAt = body.IndexOf("//", StringComparison.Ordinal);
|
||||||
if (commentAt >= 0)
|
if (commentAt >= 0)
|
||||||
{
|
{
|
||||||
@@ -191,7 +153,7 @@ public sealed class KeyboardConfig
|
|||||||
|
|
||||||
body = body.Trim();
|
body = body.Trim();
|
||||||
if (body.Length == 0)
|
if (body.Length == 0)
|
||||||
return false; // blank or pure-comment line
|
return false;
|
||||||
|
|
||||||
var tokens = body.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
var tokens = body.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (tokens.Length == 0)
|
if (tokens.Length == 0)
|
||||||
@@ -200,7 +162,6 @@ public sealed class KeyboardConfig
|
|||||||
binding.Command = tokens[0];
|
binding.Command = tokens[0];
|
||||||
binding.Description = description;
|
binding.Description = description;
|
||||||
|
|
||||||
// Everything after the command is modifiers + (at most) one key.
|
|
||||||
bool shift = false, ctrl = false;
|
bool shift = false, ctrl = false;
|
||||||
string key = "none";
|
string key = "none";
|
||||||
for (int i = 1; i < tokens.Length; i++)
|
for (int i = 1; i < tokens.Length; i++)
|
||||||
@@ -208,7 +169,7 @@ public sealed class KeyboardConfig
|
|||||||
string t = tokens[i];
|
string t = tokens[i];
|
||||||
if (t.Equals("shift", StringComparison.OrdinalIgnoreCase)) shift = true;
|
if (t.Equals("shift", StringComparison.OrdinalIgnoreCase)) shift = true;
|
||||||
else if (t.Equals("ctrl", StringComparison.OrdinalIgnoreCase)) ctrl = true;
|
else if (t.Equals("ctrl", StringComparison.OrdinalIgnoreCase)) ctrl = true;
|
||||||
else key = t; // last non-modifier token wins as the key
|
else key = t;
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.Shift = shift;
|
binding.Shift = shift;
|
||||||
@@ -217,7 +178,6 @@ public sealed class KeyboardConfig
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Rewrites each binding's line, then serialises the file to text.</summary>
|
|
||||||
public string ToText()
|
public string ToText()
|
||||||
{
|
{
|
||||||
foreach (var b in Bindings)
|
foreach (var b in Bindings)
|
||||||
@@ -229,7 +189,6 @@ public sealed class KeyboardConfig
|
|||||||
return string.Join(Environment.NewLine, _lines);
|
return string.Join(Environment.NewLine, _lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Formats one binding as <c>command [shift] [ctrl] key // comment</c>.</summary>
|
|
||||||
private static string FormatLine(KeyBinding b)
|
private static string FormatLine(KeyBinding b)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
@@ -243,7 +202,6 @@ public sealed class KeyboardConfig
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Writes the bindings back to the per-user keyboard file.</summary>
|
|
||||||
public void Save()
|
public void Save()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_savePath))
|
if (string.IsNullOrEmpty(_savePath))
|
||||||
@@ -259,7 +217,7 @@ public sealed class KeyboardConfig
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Could not write (permissions / path). Leave values in memory.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,11 @@ using System.Text;
|
|||||||
|
|
||||||
namespace StarterNG.Classes;
|
namespace StarterNG.Classes;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class Physics
|
public sealed class Physics
|
||||||
{
|
{
|
||||||
public double Mass; // kg, as written in the .fiz
|
public double Mass;
|
||||||
public double VMax; // km/h
|
public double VMax;
|
||||||
public double Length; // metres
|
public double Length;
|
||||||
public string LoadAccepted = "";
|
public string LoadAccepted = "";
|
||||||
public int MaxLoad;
|
public int MaxLoad;
|
||||||
public int AllowedFlagA = 3;
|
public int AllowedFlagA = 3;
|
||||||
@@ -26,12 +20,8 @@ public sealed class Physics
|
|||||||
|
|
||||||
private static readonly Dictionary<string, Physics?> Cache = new(StringComparer.OrdinalIgnoreCase);
|
private static readonly Dictionary<string, Physics?> 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<string, string>? _fizIndex;
|
private static Dictionary<string, string>? _fizIndex;
|
||||||
|
|
||||||
/// <summary>Builds the .fiz index up front (call from the startup load).</summary>
|
|
||||||
public static void PreloadIndex(string dynamicRoot = "dynamic") => FizIndex(dynamicRoot);
|
public static void PreloadIndex(string dynamicRoot = "dynamic") => FizIndex(dynamicRoot);
|
||||||
|
|
||||||
private static Dictionary<string, string> FizIndex(string dynamicRoot)
|
private static Dictionary<string, string> FizIndex(string dynamicRoot)
|
||||||
@@ -46,23 +36,18 @@ public sealed class Physics
|
|||||||
foreach (string f in Directory.EnumerateFiles(dynamicRoot, "*.fiz", SearchOption.AllDirectories))
|
foreach (string f in Directory.EnumerateFiles(dynamicRoot, "*.fiz", SearchOption.AllDirectories))
|
||||||
index[Path.GetFileNameWithoutExtension(f)] = f;
|
index[Path.GetFileNameWithoutExtension(f)] = f;
|
||||||
}
|
}
|
||||||
catch { /* unreadable dynamic/ - no physics */ }
|
catch { }
|
||||||
|
|
||||||
_fizIndex = index;
|
_fizIndex = index;
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Loads (and caches) the physics for a model name (the .fiz is located by name
|
|
||||||
/// anywhere under dynamic/). The <paramref name="dataFolder"/> is unused now but
|
|
||||||
/// kept for call-site clarity. Returns null when nothing matches. Never throws.
|
|
||||||
/// </summary>
|
|
||||||
public static Physics? For(string? dataFolder, string? model, string dynamicRoot = "dynamic")
|
public static Physics? For(string? dataFolder, string? model, string dynamicRoot = "dynamic")
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(model))
|
if (string.IsNullOrEmpty(model))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
string m = Path.GetFileNameWithoutExtension(model!); // strip any .mmd/.t3d/etc.
|
string m = Path.GetFileNameWithoutExtension(model!);
|
||||||
if (m.Length == 0)
|
if (m.Length == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
@@ -77,12 +62,10 @@ public sealed class Physics
|
|||||||
Physics? phys = null;
|
Physics? phys = null;
|
||||||
if (path != null)
|
if (path != null)
|
||||||
{
|
{
|
||||||
phys = new Physics { AllowedFlagB = -1 }; // -1 = "B end not declared yet"
|
phys = new Physics { AllowedFlagB = -1 };
|
||||||
try { Parse(phys, path, Path.GetDirectoryName(path) ?? "", null, 0); }
|
try { Parse(phys, path, Path.GetDirectoryName(path) ?? "", null, 0); }
|
||||||
catch { /* partial data still useful */ }
|
catch { }
|
||||||
|
|
||||||
// The original copies the A-end coupler onto the B-end when only one
|
|
||||||
// BuffCoupl section is declared.
|
|
||||||
if (phys.AllowedFlagB == -1)
|
if (phys.AllowedFlagB == -1)
|
||||||
{
|
{
|
||||||
phys.AllowedFlagB = phys.AllowedFlagA;
|
phys.AllowedFlagB = phys.AllowedFlagA;
|
||||||
@@ -116,7 +99,6 @@ public sealed class Physics
|
|||||||
if (tok == "=")
|
if (tok == "=")
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// include <file> <params...> end -> parse the included physics
|
|
||||||
if (tok.Equals("include", StringComparison.OrdinalIgnoreCase))
|
if (tok.Equals("include", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
if (++i >= tokens.Count) break;
|
if (++i >= tokens.Count) break;
|
||||||
@@ -149,13 +131,11 @@ public sealed class Physics
|
|||||||
}
|
}
|
||||||
else if (KnownKeys.Contains(t))
|
else if (KnownKeys.Contains(t))
|
||||||
{
|
{
|
||||||
pendingKey = t; // value comes in a following token (spaced "Key = value")
|
pendingKey = t;
|
||||||
}
|
}
|
||||||
else
|
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();
|
section = new string(t.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,7 +168,6 @@ public sealed class Physics
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A negative AllowedFlag means a permanent coupling: abs + 128 (workshop-lock bit).
|
|
||||||
private static int Flag(string v, int fallback)
|
private static int Flag(string v, int fallback)
|
||||||
=> int.TryParse(v, System.Globalization.NumberStyles.Integer,
|
=> int.TryParse(v, System.Globalization.NumberStyles.Integer,
|
||||||
System.Globalization.CultureInfo.InvariantCulture, out int f)
|
System.Globalization.CultureInfo.InvariantCulture, out int f)
|
||||||
@@ -201,8 +180,6 @@ public sealed class Physics
|
|||||||
? d
|
? d
|
||||||
: fallback;
|
: 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)
|
private static string Resolve(string token, string[]? prms)
|
||||||
{
|
{
|
||||||
if (prms == null || token.Length < 3 || token[0] != '(' || token[^1] != ')')
|
if (prms == null || token.Length < 3 || token[0] != '(' || token[^1] != ')')
|
||||||
|
|||||||
@@ -17,30 +17,18 @@ public enum PresetSort
|
|||||||
Track
|
Track
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One saved consist in the user's vehicle warehouse.</summary>
|
|
||||||
public sealed class TrainsetPreset
|
public sealed class TrainsetPreset
|
||||||
{
|
{
|
||||||
/// <summary>Display name shown in the "load from warehouse" menu.</summary>
|
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>Complete "trainset … endtrainset" scenery entry (self-contained).</summary>
|
|
||||||
public string Entry { get; set; } = "";
|
public string Entry { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Root object persisted to userpresets.json.</summary>
|
|
||||||
public sealed class PresetCollection
|
public sealed class PresetCollection
|
||||||
{
|
{
|
||||||
public List<TrainsetPreset> Presets { get; set; } = new();
|
public List<TrainsetPreset> Presets { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user's vehicle/consist "warehouse": named trainset presets persisted as JSON.
|
|
||||||
/// Stored under the per-user config directory so it survives reinstalls:
|
|
||||||
/// Windows: %APPDATA%\MaSzyna\starter\userpresets.json
|
|
||||||
/// macOS: ~/Library/Application Support/MaSzyna/starter/userpresets.json
|
|
||||||
/// Linux: ~/.config/MaSzyna/starter/userpresets.json
|
|
||||||
/// All operations are best-effort and never throw.
|
|
||||||
/// </summary>
|
|
||||||
public static class PresetStore
|
public static class PresetStore
|
||||||
{
|
{
|
||||||
private static string ResolvePath()
|
private static string ResolvePath()
|
||||||
@@ -63,11 +51,8 @@ public static class PresetStore
|
|||||||
|
|
||||||
public static string FilePath { get; } = ResolvePath();
|
public static string FilePath { get; } = ResolvePath();
|
||||||
|
|
||||||
/// <summary>Pascal miSortByVehicleName / miSortByTrackName for the warehouse menu.</summary>
|
|
||||||
public static PresetSort SortMode { get; set; } = PresetSort.Name;
|
public static PresetSort SortMode { get; set; } = PresetSort.Name;
|
||||||
|
|
||||||
// Source-generated metadata keeps (de)serialisation AOT/trim-safe, matching the
|
|
||||||
// approach used for the vehicle database.
|
|
||||||
private static readonly JsonSerializerOptions Options = new()
|
private static readonly JsonSerializerOptions Options = new()
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
@@ -77,7 +62,6 @@ public static class PresetStore
|
|||||||
private static JsonTypeInfo<PresetCollection> TypeInfo =>
|
private static JsonTypeInfo<PresetCollection> TypeInfo =>
|
||||||
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
|
(JsonTypeInfo<PresetCollection>)Options.GetTypeInfo(typeof(PresetCollection));
|
||||||
|
|
||||||
/// <summary>All saved presets, sorted per <see cref="SortMode"/>.</summary>
|
|
||||||
public static IReadOnlyList<TrainsetPreset> All()
|
public static IReadOnlyList<TrainsetPreset> All()
|
||||||
{
|
{
|
||||||
var list = Load().Presets.ToList();
|
var list = Load().Presets.ToList();
|
||||||
@@ -88,10 +72,6 @@ public static class PresetStore
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Saves (or overwrites, by name) a consist preset. No-op on a blank name or
|
|
||||||
/// empty entry.
|
|
||||||
/// </summary>
|
|
||||||
public static void Save(string? name, string? entry)
|
public static void Save(string? name, string? entry)
|
||||||
{
|
{
|
||||||
name = name?.Trim() ?? "";
|
name = name?.Trim() ?? "";
|
||||||
@@ -105,7 +85,6 @@ public static class PresetStore
|
|||||||
Persist(col);
|
Persist(col);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Removes a preset by name (case-insensitive).</summary>
|
|
||||||
public static void Delete(string name)
|
public static void Delete(string name)
|
||||||
{
|
{
|
||||||
var col = Load();
|
var col = Load();
|
||||||
@@ -113,10 +92,6 @@ public static class PresetStore
|
|||||||
Persist(col);
|
Persist(col);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Import Pascal <c>starter/magazyn.ini</c> (or legacy starter.ini / RAINSTED.INI)
|
|
||||||
/// into the JSON warehouse. Returns how many consists were added.
|
|
||||||
/// </summary>
|
|
||||||
public static int ImportMagazyn(string? path = null)
|
public static int ImportMagazyn(string? path = null)
|
||||||
{
|
{
|
||||||
path ??= FindMagazynPath();
|
path ??= FindMagazynPath();
|
||||||
@@ -197,7 +172,7 @@ public static class PresetStore
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// unreadable / malformed - start from an empty warehouse
|
|
||||||
}
|
}
|
||||||
return new PresetCollection();
|
return new PresetCollection();
|
||||||
}
|
}
|
||||||
@@ -211,24 +186,15 @@ public static class PresetStore
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// best-effort: a failed save just means the warehouse isn't updated
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Helpers to convert a consist to/from its canonical scenery text - the full
|
|
||||||
/// "trainset … endtrainset" block, exactly what is placed on the clipboard.
|
|
||||||
/// </summary>
|
|
||||||
public static class ConsistText
|
public static class ConsistText
|
||||||
{
|
{
|
||||||
/// <summary>The complete scenery entry for a trainset (incl. endtrainset).</summary>
|
|
||||||
public static string Serialize(Trainset trainset) => trainset.ToSceneryEntry();
|
public static string Serialize(Trainset trainset) => trainset.ToSceneryEntry();
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses the vehicles out of a complete trainset entry. Returns null when the
|
|
||||||
/// text holds no usable node::dynamic vehicles.
|
|
||||||
/// </summary>
|
|
||||||
public static List<Dynamic>? VehiclesFrom(string? text)
|
public static List<Dynamic>? VehiclesFrom(string? text)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
@@ -245,7 +211,6 @@ public static class ConsistText
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source-generated JSON metadata (AOT/trim-safe; no runtime reflection).
|
|
||||||
[JsonSerializable(typeof(PresetCollection))]
|
[JsonSerializable(typeof(PresetCollection))]
|
||||||
[JsonSerializable(typeof(TrainsetPreset))]
|
[JsonSerializable(typeof(TrainsetPreset))]
|
||||||
internal partial class PresetJsonContext : JsonSerializerContext
|
internal partial class PresetJsonContext : JsonSerializerContext
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ public sealed class SceneryInclude
|
|||||||
{
|
{
|
||||||
public string FilePath = "";
|
public string FilePath = "";
|
||||||
public string Desc = "";
|
public string Desc = "";
|
||||||
/// <summary>0 = default off, 1 = default on, 2 = terrain (auto if no .sbt).</summary>
|
|
||||||
public int Kind;
|
public int Kind;
|
||||||
public bool Selected;
|
public bool Selected;
|
||||||
}
|
}
|
||||||
@@ -29,49 +28,33 @@ public class Scenery
|
|||||||
public List<Trainset> Trainsets;
|
public List<Trainset> Trainsets;
|
||||||
public List<SceneryAttachment> Attachments = new();
|
public List<SceneryAttachment> Attachments = new();
|
||||||
public List<SceneryInclude> Includes = new();
|
public List<SceneryInclude> Includes = new();
|
||||||
/// <summary>
|
|
||||||
/// Names of <c>node … dynamic</c> entries outside trainsets (kept in the
|
|
||||||
/// template on export; collected so the depot can avoid name clashes).
|
|
||||||
/// </summary>
|
|
||||||
public List<string> LooseVehicleNames = new();
|
public List<string> LooseVehicleNames = new();
|
||||||
/// <summary>Parsed loose dynamics (Pascal <c>SCN.Vehicles</c>).</summary>
|
|
||||||
public List<Dynamic> LooseVehicles = new();
|
public List<Dynamic> LooseVehicles = new();
|
||||||
/// <summary>Parse / vehicle faults for the Info tab (Pascal FaultList).</summary>
|
|
||||||
public List<string> Faults = new();
|
public List<string> Faults = new();
|
||||||
public string Group;
|
public string Group;
|
||||||
public string Path;
|
public string Path;
|
||||||
|
|
||||||
// Starter directives (comment-syntax metadata, see wiki "Plik scenerii").
|
public string Name;
|
||||||
// They do not affect the simulation, only how a starter presents the scenery.
|
public string Description;
|
||||||
public string Name; // //$n - scenery name
|
public string ImageName;
|
||||||
public string Description; // //$d - scenery description
|
|
||||||
public string ImageName; // //$i - main-window image (scenery thumbnail)
|
|
||||||
|
|
||||||
/// <summary>True when the scenery declares the //$a "archival" flag.</summary>
|
|
||||||
public bool Archival;
|
public bool Archival;
|
||||||
|
|
||||||
// Weather / environment. Like the original Starter, these are editable and are
|
public string Time = "10:30";
|
||||||
// written into the scenery's "config" block on launch (see RewriteWeather).
|
public string ScenarioTimeOverride = "10:30";
|
||||||
//
|
public int Day = 0;
|
||||||
// Two clocks, matching Pascal TConfig.StartTime / TConfig.Time:
|
public double Temperature = 15;
|
||||||
// Time -> classic "time … endtime" (from the SCN; UI does not edit it)
|
public int FogStart = 10;
|
||||||
// ScenarioTimeOverride -> "scenario.time.override" (what the Weather picker edits)
|
public int FogEnd = 2000;
|
||||||
// Defaults: classic start 10:30; override falls back to "now" when the SCN has neither.
|
public double Overcast = 0;
|
||||||
public string Time = "10:30"; // h:mm -> "time … endtime"
|
|
||||||
public string ScenarioTimeOverride = "10:30"; // h:mm -> "scenario.time.override"
|
|
||||||
public int Day = 0; // "movelight <day>" (day of year / season)
|
|
||||||
public double Temperature = 15; // "scenario.weather.temperature"
|
|
||||||
public int FogStart = 10; // atmo fog start (metres)
|
|
||||||
public int FogEnd = 2000; // visibility in metres (atmo fog range)
|
|
||||||
public double Overcast = 0; // atmo overcast factor (-1.5 .. 1.5)
|
|
||||||
|
|
||||||
/// <summary>True when the scenery actually declared any weather command.</summary>
|
|
||||||
public bool HasWeather;
|
public bool HasWeather;
|
||||||
|
|
||||||
/// <summary>Set once the user edits the weather, so export rewrites the config.</summary>
|
|
||||||
public bool WeatherDirty;
|
public bool WeatherDirty;
|
||||||
|
|
||||||
// Snapshot of weather as loaded from the SCN (Pascal actRestoreWeather).
|
|
||||||
private string _origTime = "10:30";
|
private string _origTime = "10:30";
|
||||||
private string _origOverride = "10:30";
|
private string _origOverride = "10:30";
|
||||||
private int _origDay;
|
private int _origDay;
|
||||||
@@ -79,11 +62,8 @@ public class Scenery
|
|||||||
private int _origFogEnd = 2000;
|
private int _origFogEnd = 2000;
|
||||||
private double _origOvercast;
|
private double _origOvercast;
|
||||||
|
|
||||||
// The file content with each trainset block replaced by a {{i}} placeholder,
|
|
||||||
// used to rebuild the .scn on export.
|
|
||||||
private readonly string _template;
|
private readonly string _template;
|
||||||
|
|
||||||
/// <summary>Display label: //$n when set (with @token i18n), otherwise the file name.</summary>
|
|
||||||
public string DisplayName
|
public string DisplayName
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -94,12 +74,10 @@ public class Scenery
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>//$d lines with @token translation applied.</summary>
|
|
||||||
public string LocalizedDescription =>
|
public string LocalizedDescription =>
|
||||||
string.IsNullOrEmpty(Description) ? "" :
|
string.IsNullOrEmpty(Description) ? "" :
|
||||||
string.Join("\n", Description.Split('\n').Select(SceneryI18n.T));
|
string.Join("\n", Description.Split('\n').Select(SceneryI18n.T));
|
||||||
|
|
||||||
// Lazily resolved, cached path to the //$i image on disk (null if not found).
|
|
||||||
private string _imagePath;
|
private string _imagePath;
|
||||||
private bool _imagePathResolved;
|
private bool _imagePathResolved;
|
||||||
|
|
||||||
@@ -109,29 +87,23 @@ public class Scenery
|
|||||||
Trainsets = new List<Trainset>();
|
Trainsets = new List<Trainset>();
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
throw new FileNotFoundException(path);
|
throw new FileNotFoundException(path);
|
||||||
var encoding = Encoding.GetEncoding(1250); // Windows-1250
|
var encoding = Encoding.GetEncoding(1250);
|
||||||
string content = File.ReadAllText(path, encoding);
|
string content = File.ReadAllText(path, encoding);
|
||||||
|
|
||||||
// property scanning - starter directives written as // comments.
|
|
||||||
// \b after the letter keeps //$d from matching //$decor, //$i from
|
|
||||||
// matching //$it, etc.
|
|
||||||
this.Group = MatchDirective(content, "l");
|
this.Group = MatchDirective(content, "l");
|
||||||
this.Name = MatchDirective(content, "n");
|
this.Name = MatchDirective(content, "n");
|
||||||
// //$d may appear on several lines; each is one line of the description.
|
|
||||||
this.Description = MatchAllDirectives(content, "d");
|
this.Description = MatchAllDirectives(content, "d");
|
||||||
this.ImageName = MatchDirective(content, "i");
|
this.ImageName = MatchDirective(content, "i");
|
||||||
// //$a marks an archival scenery (present = archival, regardless of value).
|
|
||||||
this.Archival = MatchDirective(content, "a") != null;
|
this.Archival = MatchDirective(content, "a") != null;
|
||||||
|
|
||||||
// weather/environment is read from the raw text before trainset blocks
|
|
||||||
// are stripped out below (the atmosphere commands live outside trainsets)
|
|
||||||
ParseWeather(content);
|
ParseWeather(content);
|
||||||
SnapshotWeather();
|
SnapshotWeather();
|
||||||
|
|
||||||
ParseAttachments(content);
|
ParseAttachments(content);
|
||||||
content = ParseAndStripOptionalIncludes(content);
|
content = ParseAndStripOptionalIncludes(content);
|
||||||
|
|
||||||
// parsing trainsets
|
|
||||||
List<string> trainsetEntries = new List<string>();
|
List<string> trainsetEntries = new List<string>();
|
||||||
Regex regex = new Regex(
|
Regex regex = new Regex(
|
||||||
@"trainset\b[\s\S]*?\bendtrainset\b",
|
@"trainset\b[\s\S]*?\bendtrainset\b",
|
||||||
@@ -146,8 +118,6 @@ public class Scenery
|
|||||||
content = ExtractLooseVehicles(content);
|
content = ExtractLooseVehicles(content);
|
||||||
_template = content;
|
_template = content;
|
||||||
|
|
||||||
// 1:1 with placeholders - the Trainset ctor never throws (unparsable
|
|
||||||
// blocks are kept verbatim), so indices stay aligned for export.
|
|
||||||
foreach (string trainsetEntry in trainsetEntries)
|
foreach (string trainsetEntry in trainsetEntries)
|
||||||
{
|
{
|
||||||
var ts = new Trainset(trainsetEntry);
|
var ts = new Trainset(trainsetEntry);
|
||||||
@@ -157,8 +127,6 @@ public class Scenery
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull standalone node…dynamic blocks out of the template (Pascal SCN.Vehicles)
|
|
||||||
// so export can re-emit them via PrepareNode(false) and names stay editable.
|
|
||||||
private string ExtractLooseVehicles(string content)
|
private string ExtractLooseVehicles(string content)
|
||||||
{
|
{
|
||||||
LooseVehicles.Clear();
|
LooseVehicles.Clear();
|
||||||
@@ -212,10 +180,6 @@ public class Scenery
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Rebuilds the full .scn content with the (possibly modified) trainsets
|
|
||||||
/// substituted back into their original positions.
|
|
||||||
/// </summary>
|
|
||||||
public string BuildExportContent(bool skipDecorTrainsets = false)
|
public string BuildExportContent(bool skipDecorTrainsets = false)
|
||||||
{
|
{
|
||||||
string result = _template;
|
string result = _template;
|
||||||
@@ -260,7 +224,7 @@ public class Scenery
|
|||||||
string rest = m.Groups[1].Value.Trim();
|
string rest = m.Groups[1].Value.Trim();
|
||||||
if (rest.Length == 0) continue;
|
if (rest.Length == 0) continue;
|
||||||
string[] parts = rest.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
string[] parts = rest.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||||
// "$f XX path label…" (Pascal) or "$f path label"
|
|
||||||
if (parts.Length >= 3 && parts[0].Length <= 2)
|
if (parts.Length >= 3 && parts[0].Length <= 2)
|
||||||
Attachments.Add(new SceneryAttachment
|
Attachments.Add(new SceneryAttachment
|
||||||
{
|
{
|
||||||
@@ -282,8 +246,6 @@ public class Scenery
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pulls include … end //$optional… … endoptional out of the template so Start
|
|
||||||
// can re-inject only the checked ones (Pascal ParseInc / actStartExecute).
|
|
||||||
private string ParseAndStripOptionalIncludes(string content)
|
private string ParseAndStripOptionalIncludes(string content)
|
||||||
{
|
{
|
||||||
Includes.Clear();
|
Includes.Clear();
|
||||||
@@ -346,16 +308,9 @@ public class Scenery
|
|||||||
return sb + text;
|
return sb + text;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads the scenery's environment commands into the editable weather fields.
|
|
||||||
/// Mirrors the original Starter (config: movelight / scenario.weather.temperature
|
|
||||||
/// / scenario.time.override, plus top-level time and the atmo fog/overcast block).
|
|
||||||
/// </summary>
|
|
||||||
private void ParseWeather(string content)
|
private void ParseWeather(string content)
|
||||||
{
|
{
|
||||||
// Classic "time h:mm" seeds both clocks (Pascal: Config.Time + Config.StartTime).
|
|
||||||
// "scenario.time.override" seeds only the UI clock (Pascal ignored it on load;
|
|
||||||
// we honour it so a previously exported $scn round-trips correctly).
|
|
||||||
var time = Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b");
|
var time = Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b");
|
||||||
if (time.Success)
|
if (time.Success)
|
||||||
{
|
{
|
||||||
@@ -371,12 +326,11 @@ public class Scenery
|
|||||||
}
|
}
|
||||||
else if (!time.Success)
|
else if (!time.Success)
|
||||||
{
|
{
|
||||||
// No clock in the SCN → UI shows "now", classic export stays 10:30 (Pascal).
|
|
||||||
var now = System.DateTime.Now;
|
var now = System.DateTime.Now;
|
||||||
ScenarioTimeOverride = $"{now.Hour:D2}:{now.Minute:D2}";
|
ScenarioTimeOverride = $"{now.Hour:D2}:{now.Minute:D2}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// "movelight <day>" - day of the year (sun elevation / season)
|
|
||||||
var move = Regex.Match(content, @"(?im)\bmovelight\s+(-?\d+)");
|
var move = Regex.Match(content, @"(?im)\bmovelight\s+(-?\d+)");
|
||||||
if (move.Success && int.TryParse(move.Groups[1].Value, out int day))
|
if (move.Success && int.TryParse(move.Groups[1].Value, out int day))
|
||||||
{
|
{
|
||||||
@@ -384,7 +338,6 @@ public class Scenery
|
|||||||
HasWeather = true;
|
HasWeather = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "scenario.weather.temperature <°C>"
|
|
||||||
var temp = Regex.Match(content, @"(?i)scenario\.weather\.temperature\s+(-?\d+(?:\.\d+)?)");
|
var temp = Regex.Match(content, @"(?i)scenario\.weather\.temperature\s+(-?\d+(?:\.\d+)?)");
|
||||||
if (temp.Success &&
|
if (temp.Success &&
|
||||||
double.TryParse(temp.Groups[1].Value, System.Globalization.NumberStyles.Float,
|
double.TryParse(temp.Groups[1].Value, System.Globalization.NumberStyles.Float,
|
||||||
@@ -394,8 +347,6 @@ public class Scenery
|
|||||||
HasWeather = true;
|
HasWeather = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "atmo R G B fogStart fogEnd R G B overcast endatmo"
|
|
||||||
// Pascal ParseAtmo: FogEnd := RandomRange(FogStart, FogEnd).
|
|
||||||
var atmo = Regex.Match(content, @"(?is)\batmo\b(.*?)\bendatmo\b");
|
var atmo = Regex.Match(content, @"(?is)\batmo\b(.*?)\bendatmo\b");
|
||||||
if (atmo.Success)
|
if (atmo.Success)
|
||||||
{
|
{
|
||||||
@@ -429,7 +380,6 @@ public class Scenery
|
|||||||
_origOvercast = Overcast;
|
_origOvercast = Overcast;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Restores weather fields to the values parsed from the SCN file.</summary>
|
|
||||||
public void RestoreWeather()
|
public void RestoreWeather()
|
||||||
{
|
{
|
||||||
Time = _origTime;
|
Time = _origTime;
|
||||||
@@ -441,17 +391,11 @@ public class Scenery
|
|||||||
WeatherDirty = true;
|
WeatherDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Replaces the scenery's environment commands with a fresh config block built
|
|
||||||
/// from the current weather fields (the C# equivalent of the original Starter's
|
|
||||||
/// TLexParser.ChangeConfig). On any failure the original text is returned
|
|
||||||
/// unchanged so a launch is never blocked by a bad rewrite.
|
|
||||||
/// </summary>
|
|
||||||
private string RewriteWeather(string text)
|
private string RewriteWeather(string text)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// strip the existing weather commands
|
|
||||||
string s = text;
|
string s = text;
|
||||||
s = Regex.Replace(s, @"(?is)\bconfig\b.*?\bendconfig\b", " ");
|
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)\btime\b\s+\d[^\r\n]*?\bendtime\b", " ");
|
||||||
@@ -461,7 +405,7 @@ public class Scenery
|
|||||||
s = Regex.Replace(s, @"(?i)scenario\.time\.override\s+\S+", " ");
|
s = Regex.Replace(s, @"(?i)scenario\.time\.override\s+\S+", " ");
|
||||||
|
|
||||||
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
// Override = picker value; classic time = original SCN start (unchanged by UI).
|
|
||||||
string config =
|
string config =
|
||||||
"config\r\n" +
|
"config\r\n" +
|
||||||
$"movelight {Day}\r\n" +
|
$"movelight {Day}\r\n" +
|
||||||
@@ -479,10 +423,6 @@ public class Scenery
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Collects every //$<letter> line (e.g. all //$d lines) and joins them
|
|
||||||
/// into one multi-line string. Returns null when none are present.
|
|
||||||
/// </summary>
|
|
||||||
private static string MatchAllDirectives(string content, string letter)
|
private static string MatchAllDirectives(string content, string letter)
|
||||||
{
|
{
|
||||||
var matches = Regex.Matches(
|
var matches = Regex.Matches(
|
||||||
@@ -495,11 +435,6 @@ public class Scenery
|
|||||||
return string.Join("\n", matches.Select(m => m.Groups[1].Value.TrimEnd()));
|
return string.Join("\n", matches.Select(m => m.Groups[1].Value.TrimEnd()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads a single starter directive value (the text after //$<letter>).
|
|
||||||
/// Returns null when the directive is absent. The trailing whitespace
|
|
||||||
/// requirement separates e.g. //$d from //$decor and //$i from //$it.
|
|
||||||
/// </summary>
|
|
||||||
private static string MatchDirective(string content, string letter)
|
private static string MatchDirective(string content, string letter)
|
||||||
{
|
{
|
||||||
var match = Regex.Match(
|
var match = Regex.Match(
|
||||||
@@ -510,11 +445,6 @@ public class Scenery
|
|||||||
return match.Success ? match.Groups[1].Value.Trim() : null;
|
return match.Success ? match.Groups[1].Value.Trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resolved on-disk path of the //$i scenery image, or null if none is
|
|
||||||
/// declared or the file cannot be found. The value of //$i may be a bare
|
|
||||||
/// file name or a path; common locations are probed and the result cached.
|
|
||||||
/// </summary>
|
|
||||||
public string ImagePath
|
public string ImagePath
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -532,19 +462,17 @@ public class Scenery
|
|||||||
if (string.IsNullOrWhiteSpace(ImageName))
|
if (string.IsNullOrWhiteSpace(ImageName))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
// normalise legacy back-slashes so paths work cross-platform
|
|
||||||
string name = ImageName.Replace('\\', '/').Trim();
|
string name = ImageName.Replace('\\', '/').Trim();
|
||||||
string scnDir = System.IO.Path.GetDirectoryName(Path) ?? "."; // e.g. scenery/
|
string scnDir = System.IO.Path.GetDirectoryName(Path) ?? ".";
|
||||||
string root = System.IO.Path.GetDirectoryName(scnDir) ?? "."; // MaSzyna root
|
string root = System.IO.Path.GetDirectoryName(scnDir) ?? ".";
|
||||||
|
|
||||||
// probe the usual places, first hit wins
|
|
||||||
var candidates = new List<string>
|
var candidates = new List<string>
|
||||||
{
|
{
|
||||||
name, // as given (cwd / absolute)
|
name,
|
||||||
System.IO.Path.Combine(root, name), // relative to MaSzyna root
|
System.IO.Path.Combine(root, name),
|
||||||
System.IO.Path.Combine(scnDir, name), // next to the .scn
|
System.IO.Path.Combine(scnDir, name),
|
||||||
System.IO.Path.Combine(scnDir, "images", name), // scenery/images/
|
System.IO.Path.Combine(scnDir, "images", name),
|
||||||
System.IO.Path.Combine(root, "scenery", "images", name), // scenery/images/ from root
|
System.IO.Path.Combine(root, "scenery", "images", name),
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (string candidate in candidates)
|
foreach (string candidate in candidates)
|
||||||
|
|||||||
@@ -9,163 +9,126 @@ namespace StarterNG.Classes;
|
|||||||
|
|
||||||
public enum BatteryDefault { Default = 0, AlwaysOff = 1, AlwaysOn = 2 }
|
public enum BatteryDefault { Default = 0, AlwaysOff = 1, AlwaysOn = 2 }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Application settings backing the Settings view, persisted to the simulator's
|
|
||||||
/// <c>eu07.ini</c>.
|
|
||||||
///
|
|
||||||
/// <para><b>Where it loads/saves.</b> The active file lives next to the
|
|
||||||
/// simulator's own config: <c>%APPDATA%\MaSzyna\eu07.ini</c> on Windows or
|
|
||||||
/// <c>~/.config/MaSzyna/eu07.ini</c> elsewhere. If that file does not exist yet,
|
|
||||||
/// defaults are read from an <c>eu07.ini</c> in the launcher's working directory
|
|
||||||
/// (the same fallback the simulator uses). Saving always writes to the per-user
|
|
||||||
/// path.</para>
|
|
||||||
///
|
|
||||||
/// <para><b>Unknown keys are kept.</b> The whole file is round-tripped through
|
|
||||||
/// <see cref="ConfigFile"/>; only the keys the UI actually edits are rewritten,
|
|
||||||
/// so any extra settings the launcher does not model survive a save.</para>
|
|
||||||
///
|
|
||||||
/// <para>Some mappings are best-effort where the wiki does not document an exact
|
|
||||||
/// key; those are marked with NOTE and can be adjusted against the running exe.</para>
|
|
||||||
/// </summary>
|
|
||||||
public sealed class Settings
|
public sealed class Settings
|
||||||
{
|
{
|
||||||
public static Settings Instance { get; } = new();
|
public static Settings Instance { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pulls the current values out of the Settings view into this instance.
|
|
||||||
/// The view assigns this when it is constructed; null if the view was never
|
|
||||||
/// opened (in which case Save just re-writes the loaded values).
|
|
||||||
/// </summary>
|
|
||||||
public Action? CaptureFromUi { get; set; }
|
public Action? CaptureFromUi { get; set; }
|
||||||
|
|
||||||
/// <summary>True if the loaded config explicitly contained a <c>lang</c> entry.</summary>
|
|
||||||
public bool LanguageWasSet { get; private set; }
|
public bool LanguageWasSet { get; private set; }
|
||||||
|
|
||||||
// ── General ───────────────────────────────────────────────────────────
|
public string Language = "English";
|
||||||
public string Language = "English"; // lang (pl/en)
|
public bool Fullscreen;
|
||||||
public bool Fullscreen; // fullscreen
|
public bool PauseWhenInactive = true;
|
||||||
public bool PauseWhenInactive = true; // inactivepause
|
public bool PauseOnStart;
|
||||||
public bool PauseOnStart; // pause
|
public int CursorSensitivity = 2;
|
||||||
public int CursorSensitivity = 2; // |mousescale x|
|
public bool InvertMouseHorizontal;
|
||||||
public bool InvertMouseHorizontal; // mousescale x < 0
|
public bool InvertMouseVertical;
|
||||||
public bool InvertMouseVertical; // mousescale y < 0
|
|
||||||
|
|
||||||
// ── Communication ─────────────────────────────────────────────────────
|
public bool IgnoreGamepad;
|
||||||
public bool IgnoreGamepad; // input.gamepad (inverted)
|
public int FeedbackMode = 1;
|
||||||
public int FeedbackMode = 1; // feedbackmode (0-5)
|
public int FeedbackPort = 888;
|
||||||
public int FeedbackPort = 888; // feedbackport
|
public bool UartEnabled;
|
||||||
public bool UartEnabled; // uart presence
|
public string UartPort = "";
|
||||||
public string UartPort = ""; // uart
|
public string UartTune = "";
|
||||||
public string UartTune = ""; // uarttune
|
public bool UartDebug;
|
||||||
public bool UartDebug; // uartdebug
|
public bool UartMain, UartScnd, UartTrain, UartLocal, UartRadioVolume, UartRadioChannel;
|
||||||
public bool UartMain, UartScnd, UartTrain, UartLocal, UartRadioVolume, UartRadioChannel; // uartfeature tokens
|
|
||||||
|
|
||||||
// ── Other ─────────────────────────────────────────────────────────────
|
public bool SelectExeAutomatically = true;
|
||||||
public bool SelectExeAutomatically = true; // starter.exe.auto (launcher-only)
|
public string ExecutablePath = "eu07.exe";
|
||||||
public string ExecutablePath = "eu07.exe"; // starter.exe.path (launcher-only)
|
public bool DebugMode;
|
||||||
public bool DebugMode; // debugmode
|
public bool VirtualShunting = true;
|
||||||
public bool VirtualShunting = true; // ai.trainman
|
public bool LogMissingVehicleFiles;
|
||||||
public bool LogMissingVehicleFiles; // starter.logmissingvehicles → bledy.txt dump
|
|
||||||
|
|
||||||
// ── Graphics ──────────────────────────────────────────────────────────
|
public int RenderEngine;
|
||||||
public int RenderEngine; // gfxrenderer (index, see RenderEngines)
|
public int Width = 1280;
|
||||||
public int Width = 1280; // width
|
public int Height = 720;
|
||||||
public int Height = 720; // height
|
public int BufferScalePercent = 100;
|
||||||
public int BufferScalePercent = 100; // starter.bufferscale → gfx.framebuffer.fidelity
|
public int MaxTextureSize = 4096;
|
||||||
public int MaxTextureSize = 4096; // maxtexturesize
|
public int MaxCabTextureSize = 4096;
|
||||||
public int MaxCabTextureSize = 4096; // maxcabtexturesize
|
public int TextureFiltering = 8;
|
||||||
public int TextureFiltering = 8; // anisotropicfiltering (1/2/4/8/16)
|
public int Multisampling = 2;
|
||||||
public int Multisampling = 2; // multisampling (0-3)
|
public int DynamicLights = 7;
|
||||||
public int DynamicLights = 7; // dynamiclights (0-7)
|
public double DrawRangeFactor = 3.0;
|
||||||
public double DrawRangeFactor = 3.0; // gfx.drawrange.factor.max
|
public bool VSync;
|
||||||
public bool VSync; // vsync
|
public bool Smoke = true;
|
||||||
public bool Smoke = true; // gfx.smoke
|
public int SmokeFidelity = 1;
|
||||||
public int SmokeFidelity = 1; // gfx.smoke.fidelity (1-4)
|
public bool ChromaticAberration = true;
|
||||||
public bool ChromaticAberration = true; // gfx.postfx.chromaticaberration.enabled
|
public bool MotionBlur = true;
|
||||||
public bool MotionBlur = true; // gfx.postfx.motionblur.enabled
|
public bool ExtraEffects = true;
|
||||||
public bool ExtraEffects = true; // gfx.extraeffects
|
public bool EnvMap = true;
|
||||||
public bool EnvMap = true; // gfx.envmap.enabled
|
public bool UseVbo = true;
|
||||||
public bool UseVbo = true; // usevbo
|
public bool RenderShadows = true;
|
||||||
public bool RenderShadows = true; // shadows + gfx.shadowmap.enabled
|
public int ReflectionsFramerate = 60;
|
||||||
public int ReflectionsFramerate = 60; // gfx.reflections.framerate
|
public int ShadowMapResolution = 2048;
|
||||||
public int ShadowMapResolution = 2048; // shadowtune[0]
|
public int ShadowProjectionRange = 250;
|
||||||
public int ShadowProjectionRange = 250; // shadowtune[1]
|
public int CabShadowsRange;
|
||||||
public int CabShadowsRange; // gfx.shadows.cab.range
|
public int ShadowRankCutoff = 3;
|
||||||
public int ShadowRankCutoff = 3; // gfx.shadow.rank.cutoff (NOTE)
|
public int ReflectionsFidelity;
|
||||||
public int ReflectionsFidelity; // gfx.reflections.fidelity (0-2)
|
public int FieldOfView = 45;
|
||||||
public int FieldOfView = 45; // fieldofview (15-75) NOTE: 'fovSlider' in XAML
|
public bool PythonScreens = true;
|
||||||
public bool PythonScreens = true; // python.enabled
|
public bool PythonThreadedUpload = true;
|
||||||
public bool PythonThreadedUpload = true; // python.threadedupload
|
public bool FpsLimitEnabled;
|
||||||
public bool FpsLimitEnabled; // fpslimit (presence)
|
public int FpsLimit = 30;
|
||||||
public int FpsLimit = 30; // fpslimit (1-100)
|
public double ShadowAngleLimit = 0.2;
|
||||||
public double ShadowAngleLimit = 0.2; // gfx.shadow.angle.min (0.2-1.0)
|
public bool FullscreenWindowed;
|
||||||
public bool FullscreenWindowed; // fullscreenwindowed
|
public bool RenderAngleVulkan;
|
||||||
public bool RenderAngleVulkan; // gfx.angleplatform ("vulkan")
|
|
||||||
|
|
||||||
// ── Physics ───────────────────────────────────────────────────────────
|
public int SplineFidelity = 1;
|
||||||
public int SplineFidelity = 1; // splinefidelity (1-4)
|
public bool FullPhysics = true;
|
||||||
public bool FullPhysics = true; // fullphysics
|
public bool EnableTraction = true;
|
||||||
public bool EnableTraction = true; // enabletraction (pantograph can break)
|
public bool LiveTraction = true;
|
||||||
public bool LiveTraction = true; // livetraction
|
public bool PhysicsLog;
|
||||||
public bool PhysicsLog; // physicslog (speedometer tapes)
|
public bool DebugLog = true;
|
||||||
public bool DebugLog = true; // debuglog (3 / 0)
|
public bool MultipleLogs;
|
||||||
public bool MultipleLogs; // multiplelogs
|
public bool DisplaySimulation = true;
|
||||||
public bool DisplaySimulation = true; // gfx.skiprendering (inverted)
|
public bool CrashDamage = true;
|
||||||
public bool CrashDamage = true; // crashdamage
|
public double Friction = 1.0;
|
||||||
public double Friction = 1.0; // friction (0.1-3.0)
|
public double BrakeStep = 1.0;
|
||||||
public double BrakeStep = 1.0; // brakestep (0.4-3.0)
|
public double BrakeSpeed = 3.0;
|
||||||
public double BrakeSpeed = 3.0; // brakespeed (0.4-3.0)
|
|
||||||
|
|
||||||
// ── Sound ─────────────────────────────────────────────────────────────
|
public bool SoundEnabled = true;
|
||||||
public bool SoundEnabled = true; // soundenabled
|
public int Volume = 50;
|
||||||
public int Volume = 50; // sound.volume (0-2 → 0-100)
|
public int RadioVolume = 75;
|
||||||
public int RadioVolume = 75; // sound.volume.radio (0-1 → 0-100)
|
public int VehiclesVolume = 100;
|
||||||
public int VehiclesVolume = 100; // sound.volume.vehicle
|
public int PositionalVolume = 100;
|
||||||
public int PositionalVolume = 100; // sound.volume.positional
|
public int AmbientVolume = 100;
|
||||||
public int AmbientVolume = 100; // sound.volume.ambient
|
|
||||||
|
|
||||||
// ── Advanced ──────────────────────────────────────────────────────────
|
public bool CompressTextures = true;
|
||||||
public bool CompressTextures = true; // compresstex
|
public bool ScaleSpeculars = true;
|
||||||
public bool ScaleSpeculars = true; // scalespeculars
|
public bool UseGLES;
|
||||||
public bool UseGLES; // gfx.usegles
|
public bool ShaderGamma;
|
||||||
public bool ShaderGamma; // gfx.shadergamma
|
public bool ScreenMipmaps = true;
|
||||||
public bool ScreenMipmaps = true; // python.mipmaps
|
public bool ExtendedModelConversion;
|
||||||
public bool ExtendedModelConversion; // convertmodels ("143"/"0")
|
public bool GfxResourceMove;
|
||||||
public bool GfxResourceMove; // gfx.resource.move
|
public bool GfxResourceSweep = true;
|
||||||
public bool GfxResourceSweep = true; // gfx.resource.sweep
|
public int TrainPhysicsThreads;
|
||||||
public int TrainPhysicsThreads; // async.trainThreads
|
public bool IgnoreIrrelevantTrains;
|
||||||
public bool IgnoreIrrelevantTrains; // starter.ignoreirrelevant (launcher-only)
|
|
||||||
|
|
||||||
// ── Starter (launcher-only, stored under starter.* keys) ───────────────
|
public bool AutoCloseStarter;
|
||||||
public bool AutoCloseStarter; // starter.autoclose
|
public bool LargeThumbnails;
|
||||||
public bool LargeThumbnails; // starter.largethumbnails
|
public bool AutoExpandSceneryTree;
|
||||||
public bool AutoExpandSceneryTree; // starter.expandtree
|
public BatteryDefault BatteryDefault = BatteryDefault.Default;
|
||||||
public BatteryDefault BatteryDefault = BatteryDefault.Default; // starter.batterydefault (launcher-only)
|
|
||||||
|
|
||||||
// List filters (launcher-only). Default on.
|
public bool ShowAiVehicles = true;
|
||||||
public bool ShowAiVehicles = true; // starter.show.ai (show //$o "-" consists)
|
public bool DrivableOnly = true;
|
||||||
public bool DrivableOnly = true; // starter.drivableonly (hide //$decor consists)
|
public bool ShowArchivalSceneries = true;
|
||||||
public bool ShowArchivalSceneries = true; // starter.show.archival
|
public bool HideArchivalVehicles = true;
|
||||||
public bool HideArchivalVehicles = true; // starter.hide.archivalvehicles
|
|
||||||
/// <summary>Last selected scenery file name (without path), restored on next launch.</summary>
|
public string LastScenery = "";
|
||||||
public string LastScenery = ""; // starter.last.scenery
|
|
||||||
|
|
||||||
/// <summary>gfxrenderer tokens in the order shown by the render-engine combo.</summary>
|
|
||||||
public static readonly string[] RenderEngines =
|
public static readonly string[] RenderEngines =
|
||||||
{ "full", "legacy", "simpleshader", "simple", "off", "experimental" };
|
{ "full", "legacy", "simpleshader", "simple", "off", "experimental" };
|
||||||
|
|
||||||
/// <summary>Anisotropic-filtering steps for the texture-quality slider (1-5).</summary>
|
|
||||||
public static readonly int[] AnisotropySteps = { 1, 2, 4, 8, 16 };
|
public static readonly int[] AnisotropySteps = { 1, 2, 4, 8, 16 };
|
||||||
|
|
||||||
// Tokens of mousescale / shadowtune that the UI does not edit but must keep.
|
|
||||||
private double _mouseScaleYMagnitude = 0.2;
|
private double _mouseScaleYMagnitude = 0.2;
|
||||||
private string[] _shadowTuneExtra = { "400", "300" }; // tokens [2], [3]
|
private string[] _shadowTuneExtra = { "400", "300" };
|
||||||
|
|
||||||
private ConfigFile _config = new();
|
private ConfigFile _config = new();
|
||||||
private string _savePath = string.Empty;
|
private string _savePath = string.Empty;
|
||||||
private DateTime _configAgeUtc = DateTime.MinValue;
|
private DateTime _configAgeUtc = DateTime.MinValue;
|
||||||
|
|
||||||
/// <summary>Resolves the per-user eu07.ini path for the current OS.</summary>
|
|
||||||
public static string UserConfigPath()
|
public static string UserConfigPath()
|
||||||
{
|
{
|
||||||
if (OperatingSystem.IsWindows())
|
if (OperatingSystem.IsWindows())
|
||||||
@@ -186,24 +149,16 @@ public sealed class Settings
|
|||||||
if (!string.IsNullOrEmpty(home))
|
if (!string.IsNullOrEmpty(home))
|
||||||
return Path.Combine(home, ".config", "MaSzyna", "eu07.ini");
|
return Path.Combine(home, ".config", "MaSzyna", "eu07.ini");
|
||||||
}
|
}
|
||||||
// Last resort: alongside the launcher.
|
|
||||||
return Path.Combine(AppContext.BaseDirectory, "eu07.ini");
|
return Path.Combine(AppContext.BaseDirectory, "eu07.ini");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Default config shipped in the launcher's working directory.</summary>
|
|
||||||
private static string DefaultConfigPath() =>
|
private static string DefaultConfigPath() =>
|
||||||
Path.Combine(Directory.GetCurrentDirectory(), "eu07.ini");
|
Path.Combine(Directory.GetCurrentDirectory(), "eu07.ini");
|
||||||
|
|
||||||
/// <summary>Directory holding the per-user eu07.ini, for locating launcher-only data alongside it.</summary>
|
|
||||||
public static string UserConfigDirectory() =>
|
public static string UserConfigDirectory() =>
|
||||||
Path.GetDirectoryName(UserConfigPath()) ?? AppContext.BaseDirectory;
|
Path.GetDirectoryName(UserConfigPath()) ?? AppContext.BaseDirectory;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The simulator executable to launch. With auto-selection on, the working
|
|
||||||
/// directory is scanned for an <c>eu07*</c> binary - <c>eu07</c> on Linux/macOS,
|
|
||||||
/// <c>eu07.exe</c> on Windows - so the same procedure works on every OS. With it
|
|
||||||
/// off, the explicitly chosen path is used.
|
|
||||||
/// </summary>
|
|
||||||
public string ResolveExecutable()
|
public string ResolveExecutable()
|
||||||
{
|
{
|
||||||
if (!SelectExeAutomatically && !string.IsNullOrWhiteSpace(ExecutablePath))
|
if (!SelectExeAutomatically && !string.IsNullOrWhiteSpace(ExecutablePath))
|
||||||
@@ -219,23 +174,18 @@ public sealed class Settings
|
|||||||
continue;
|
continue;
|
||||||
string file = Path.GetFileName(path);
|
string file = Path.GetFileName(path);
|
||||||
if (string.Equals(file, canonical, StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(file, canonical, StringComparison.OrdinalIgnoreCase))
|
||||||
return path; // exact canonical name wins
|
return path;
|
||||||
if (best == null || file.Length < Path.GetFileName(best).Length)
|
if (best == null || file.Length < Path.GetFileName(best).Length)
|
||||||
best = path; // otherwise prefer the shortest eu07* name
|
best = path;
|
||||||
}
|
}
|
||||||
if (best != null)
|
if (best != null)
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
catch { /* fall through to the canonical name */ }
|
catch { }
|
||||||
|
|
||||||
return canonical;
|
return canonical;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Scans <paramref name="directory"/> (defaults to the working directory) for
|
|
||||||
/// plausible <c>eu07*</c> launcher binaries, newest first, for populating the
|
|
||||||
/// manual-selection dropdown. Does not affect <see cref="ResolveExecutable"/>.
|
|
||||||
/// </summary>
|
|
||||||
public static List<string> ListCandidateExecutables(string? directory = null)
|
public static List<string> ListCandidateExecutables(string? directory = null)
|
||||||
{
|
{
|
||||||
directory ??= Directory.GetCurrentDirectory();
|
directory ??= Directory.GetCurrentDirectory();
|
||||||
@@ -250,8 +200,6 @@ public sealed class Settings
|
|||||||
catch { return new List<string>(); }
|
catch { return new List<string>(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
private static bool IsExecutableCandidate(string path)
|
||||||
{
|
{
|
||||||
string ext = Path.GetExtension(path).ToLowerInvariant();
|
string ext = Path.GetExtension(path).ToLowerInvariant();
|
||||||
@@ -260,9 +208,7 @@ public sealed class Settings
|
|||||||
return false;
|
return false;
|
||||||
if (OperatingSystem.IsWindows())
|
if (OperatingSystem.IsWindows())
|
||||||
return ext == ".exe";
|
return ext == ".exe";
|
||||||
// Linux / macOS: no extension (eu07), a known binary suffix, or .exe
|
|
||||||
// (some builds - notably this project's own build-macos.sh - ship the
|
|
||||||
// macOS binary under the historical "eu07.exe" name).
|
|
||||||
return ext.Length == 0 || ext is ".exe" or ".x86_64" or ".run" or ".appimage";
|
return ext.Length == 0 || ext is ".exe" or ".x86_64" or ".run" or ".appimage";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +216,7 @@ public sealed class Settings
|
|||||||
|
|
||||||
private static Encoding ResolveEncoding()
|
private static Encoding ResolveEncoding()
|
||||||
{
|
{
|
||||||
// eu07.ini comments use Polish diacritics in Windows-1250; preserve them.
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
@@ -282,7 +228,6 @@ public sealed class Settings
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Loads settings from the per-user file, or the working-dir default.</summary>
|
|
||||||
public void Load()
|
public void Load()
|
||||||
{
|
{
|
||||||
_savePath = UserConfigPath();
|
_savePath = UserConfigPath();
|
||||||
@@ -290,14 +235,12 @@ public sealed class Settings
|
|||||||
LoadFrom(source);
|
LoadFrom(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Captures the latest UI values (if the view is open) and saves.</summary>
|
|
||||||
public void CaptureAndSave()
|
public void CaptureAndSave()
|
||||||
{
|
{
|
||||||
CaptureFromUi?.Invoke();
|
CaptureFromUi?.Invoke();
|
||||||
Save();
|
Save();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Loads settings from an arbitrary path without changing where <see cref="Save"/> writes.</summary>
|
|
||||||
public void LoadFrom(string path)
|
public void LoadFrom(string path)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -315,7 +258,6 @@ public sealed class Settings
|
|||||||
TouchConfigAge();
|
TouchConfigAge();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Persists settings to the per-user eu07.ini, keeping unknown keys.</summary>
|
|
||||||
public void Save()
|
public void Save()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_savePath))
|
if (string.IsNullOrEmpty(_savePath))
|
||||||
@@ -324,7 +266,6 @@ public sealed class Settings
|
|||||||
SaveTo(_savePath);
|
SaveTo(_savePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Writes settings to an arbitrary path without changing where <see cref="Save"/> writes.</summary>
|
|
||||||
public void SaveTo(string path)
|
public void SaveTo(string path)
|
||||||
{
|
{
|
||||||
WriteToConfig();
|
WriteToConfig();
|
||||||
@@ -341,7 +282,7 @@ public sealed class Settings
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Could not write (permissions / path). Leave values in memory.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +301,6 @@ public sealed class Settings
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Pascal CheckSettingsFile: true when eu07.ini is newer than last load/save.</summary>
|
|
||||||
public bool IsConfigNewerOnDisk()
|
public bool IsConfigNewerOnDisk()
|
||||||
{
|
{
|
||||||
string path = string.IsNullOrEmpty(_savePath) ? UserConfigPath() : _savePath;
|
string path = string.IsNullOrEmpty(_savePath) ? UserConfigPath() : _savePath;
|
||||||
@@ -376,7 +316,6 @@ public sealed class Settings
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map BufferScalePercent → gfx.framebuffer.fidelity like Pascal's 720/1080/1440/Custom.
|
|
||||||
private void ApplyFramebufferFidelity()
|
private void ApplyFramebufferFidelity()
|
||||||
{
|
{
|
||||||
int fidelity = BufferScalePercent switch
|
int fidelity = BufferScalePercent switch
|
||||||
@@ -392,10 +331,9 @@ public sealed class Settings
|
|||||||
_config.Remove("gfx.framebuffer.fidelity");
|
_config.Remove("gfx.framebuffer.fidelity");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Copy the Reinhard GLSL into shaders/tonemapping.glsl when present.</summary>
|
|
||||||
public void ApplyHdrShader()
|
public void ApplyHdrShader()
|
||||||
{
|
{
|
||||||
// Only meaningful for full / legacy / experimental renderers (Pascal).
|
|
||||||
if (RenderEngine is not (0 or 1 or 5))
|
if (RenderEngine is not (0 or 1 or 5))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -408,7 +346,7 @@ public sealed class Settings
|
|||||||
Directory.CreateDirectory(destDir);
|
Directory.CreateDirectory(destDir);
|
||||||
File.Copy(src, Path.Combine(destDir, "tonemapping.glsl"), overwrite: true);
|
File.Copy(src, Path.Combine(destDir, "tonemapping.glsl"), overwrite: true);
|
||||||
}
|
}
|
||||||
catch { /* missing shaders dir / permissions */ }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? FindBundled(string fileName)
|
private static string? FindBundled(string fileName)
|
||||||
@@ -426,7 +364,6 @@ public sealed class Settings
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Pascal chLogExt on exit: dump missing vehicle assets to starter/bledy.txt.</summary>
|
|
||||||
public void DumpMissingVehicleLog()
|
public void DumpMissingVehicleLog()
|
||||||
{
|
{
|
||||||
if (!LogMissingVehicleFiles) return;
|
if (!LogMissingVehicleFiles) return;
|
||||||
@@ -441,12 +378,10 @@ public sealed class Settings
|
|||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── config → fields ───────────────────────────────────────────────────
|
|
||||||
private void ReadFromConfig()
|
private void ReadFromConfig()
|
||||||
{
|
{
|
||||||
var c = _config;
|
var c = _config;
|
||||||
|
|
||||||
// General
|
|
||||||
LanguageWasSet = c.Has("lang");
|
LanguageWasSet = c.Has("lang");
|
||||||
Language = c.GetString("lang", "en").ToLowerInvariant() == "pl" ? "Polski" : "English";
|
Language = c.GetString("lang", "en").ToLowerInvariant() == "pl" ? "Polski" : "English";
|
||||||
Fullscreen = c.GetBool("fullscreen", false);
|
Fullscreen = c.GetBool("fullscreen", false);
|
||||||
@@ -465,7 +400,6 @@ public sealed class Settings
|
|||||||
_mouseScaleYMagnitude = Math.Abs(my);
|
_mouseScaleYMagnitude = Math.Abs(my);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Communication
|
|
||||||
IgnoreGamepad = !c.GetBool("input.gamepad", true);
|
IgnoreGamepad = !c.GetBool("input.gamepad", true);
|
||||||
FeedbackMode = Clamp(c.GetInt("feedbackmode", 1), 0, 5);
|
FeedbackMode = Clamp(c.GetInt("feedbackmode", 1), 0, 5);
|
||||||
FeedbackPort = c.GetInt("feedbackport", 888);
|
FeedbackPort = c.GetInt("feedbackport", 888);
|
||||||
@@ -486,14 +420,12 @@ public sealed class Settings
|
|||||||
UartRadioVolume = UartRadioChannel = false;
|
UartRadioVolume = UartRadioChannel = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other
|
|
||||||
SelectExeAutomatically = c.GetBool("starter.exe.auto", true);
|
SelectExeAutomatically = c.GetBool("starter.exe.auto", true);
|
||||||
ExecutablePath = c.GetString("starter.exe.path", "eu07.exe");
|
ExecutablePath = c.GetString("starter.exe.path", "eu07.exe");
|
||||||
DebugMode = c.GetBool("debugmode", false);
|
DebugMode = c.GetBool("debugmode", false);
|
||||||
VirtualShunting = c.GetBool("ai.trainman", true);
|
VirtualShunting = c.GetBool("ai.trainman", true);
|
||||||
LogMissingVehicleFiles = c.GetBool("starter.logmissingvehicles", false);
|
LogMissingVehicleFiles = c.GetBool("starter.logmissingvehicles", false);
|
||||||
|
|
||||||
// Graphics
|
|
||||||
RenderEngine = IndexOf(RenderEngines, c.GetString("gfxrenderer", "full"), 0);
|
RenderEngine = IndexOf(RenderEngines, c.GetString("gfxrenderer", "full"), 0);
|
||||||
Width = c.GetInt("width", 1280);
|
Width = c.GetInt("width", 1280);
|
||||||
Height = c.GetInt("height", 720);
|
Height = c.GetInt("height", 720);
|
||||||
@@ -532,7 +464,6 @@ public sealed class Settings
|
|||||||
FullscreenWindowed = c.GetBool("fullscreenwindowed", false);
|
FullscreenWindowed = c.GetBool("fullscreenwindowed", false);
|
||||||
RenderAngleVulkan = string.Equals(c.GetString("gfx.angleplatform", ""), "vulkan", StringComparison.OrdinalIgnoreCase);
|
RenderAngleVulkan = string.Equals(c.GetString("gfx.angleplatform", ""), "vulkan", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Physics
|
|
||||||
SplineFidelity = Clamp(c.GetInt("splinefidelity", 1), 1, 4);
|
SplineFidelity = Clamp(c.GetInt("splinefidelity", 1), 1, 4);
|
||||||
FullPhysics = c.GetBool("fullphysics", true);
|
FullPhysics = c.GetBool("fullphysics", true);
|
||||||
EnableTraction = c.GetBool("enabletraction", true);
|
EnableTraction = c.GetBool("enabletraction", true);
|
||||||
@@ -546,7 +477,6 @@ public sealed class Settings
|
|||||||
BrakeStep = c.GetDouble("brakestep", 1.0);
|
BrakeStep = c.GetDouble("brakestep", 1.0);
|
||||||
BrakeSpeed = c.GetDouble("brakespeed", 3.0);
|
BrakeSpeed = c.GetDouble("brakespeed", 3.0);
|
||||||
|
|
||||||
// Sound
|
|
||||||
SoundEnabled = c.GetBool("soundenabled", true);
|
SoundEnabled = c.GetBool("soundenabled", true);
|
||||||
Volume = Clamp((int)Math.Round(c.GetDouble("sound.volume", 1.0) * 50), 1, 100);
|
Volume = Clamp((int)Math.Round(c.GetDouble("sound.volume", 1.0) * 50), 1, 100);
|
||||||
RadioVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.radio", 0.75) * 100), 1, 100);
|
RadioVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.radio", 0.75) * 100), 1, 100);
|
||||||
@@ -554,7 +484,6 @@ public sealed class Settings
|
|||||||
PositionalVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.positional", 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);
|
AmbientVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.ambient", 1.0) * 100), 1, 100);
|
||||||
|
|
||||||
// Advanced
|
|
||||||
CompressTextures = c.GetBool("compresstex", true);
|
CompressTextures = c.GetBool("compresstex", true);
|
||||||
ScaleSpeculars = c.GetBool("scalespeculars", true);
|
ScaleSpeculars = c.GetBool("scalespeculars", true);
|
||||||
UseGLES = c.GetBool("gfx.usegles", false);
|
UseGLES = c.GetBool("gfx.usegles", false);
|
||||||
@@ -566,7 +495,6 @@ public sealed class Settings
|
|||||||
TrainPhysicsThreads = Clamp(c.GetInt("async.trainThreads", 0), 0, Environment.ProcessorCount);
|
TrainPhysicsThreads = Clamp(c.GetInt("async.trainThreads", 0), 0, Environment.ProcessorCount);
|
||||||
IgnoreIrrelevantTrains = c.GetBool("starter.ignoreirrelevant", false);
|
IgnoreIrrelevantTrains = c.GetBool("starter.ignoreirrelevant", false);
|
||||||
|
|
||||||
// Starter
|
|
||||||
AutoCloseStarter = c.GetBool("starter.autoclose", false);
|
AutoCloseStarter = c.GetBool("starter.autoclose", false);
|
||||||
LargeThumbnails = c.GetBool("starter.largethumbnails", false);
|
LargeThumbnails = c.GetBool("starter.largethumbnails", false);
|
||||||
AutoExpandSceneryTree = c.GetBool("starter.expandtree", false);
|
AutoExpandSceneryTree = c.GetBool("starter.expandtree", false);
|
||||||
@@ -578,12 +506,10 @@ public sealed class Settings
|
|||||||
LastScenery = c.GetString("starter.last.scenery", "");
|
LastScenery = c.GetString("starter.last.scenery", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── fields → config ───────────────────────────────────────────────────
|
|
||||||
private void WriteToConfig()
|
private void WriteToConfig()
|
||||||
{
|
{
|
||||||
var c = _config;
|
var c = _config;
|
||||||
|
|
||||||
// General
|
|
||||||
c.Set("lang", Language == "Polski" ? "pl" : "en");
|
c.Set("lang", Language == "Polski" ? "pl" : "en");
|
||||||
c.SetBool("fullscreen", Fullscreen);
|
c.SetBool("fullscreen", Fullscreen);
|
||||||
c.SetBool("inactivepause", PauseWhenInactive);
|
c.SetBool("inactivepause", PauseWhenInactive);
|
||||||
@@ -595,7 +521,6 @@ public sealed class Settings
|
|||||||
$"{mx.ToString("0.###", CultureInfo.InvariantCulture)} " +
|
$"{mx.ToString("0.###", CultureInfo.InvariantCulture)} " +
|
||||||
$"{my.ToString("0.###", CultureInfo.InvariantCulture)}");
|
$"{my.ToString("0.###", CultureInfo.InvariantCulture)}");
|
||||||
|
|
||||||
// Communication
|
|
||||||
c.SetBool("input.gamepad", !IgnoreGamepad);
|
c.SetBool("input.gamepad", !IgnoreGamepad);
|
||||||
c.SetInt("feedbackmode", FeedbackMode);
|
c.SetInt("feedbackmode", FeedbackMode);
|
||||||
c.SetInt("feedbackport", FeedbackPort);
|
c.SetInt("feedbackport", FeedbackPort);
|
||||||
@@ -611,14 +536,12 @@ public sealed class Settings
|
|||||||
if (UartRadioChannel) sb.Append("radiochannel|");
|
if (UartRadioChannel) sb.Append("radiochannel|");
|
||||||
c.Set("uartfeature", sb.ToString());
|
c.Set("uartfeature", sb.ToString());
|
||||||
|
|
||||||
// Other
|
|
||||||
c.SetBool("starter.exe.auto", SelectExeAutomatically);
|
c.SetBool("starter.exe.auto", SelectExeAutomatically);
|
||||||
c.Set("starter.exe.path", ExecutablePath);
|
c.Set("starter.exe.path", ExecutablePath);
|
||||||
c.SetBool("debugmode", DebugMode);
|
c.SetBool("debugmode", DebugMode);
|
||||||
c.SetBool("ai.trainman", VirtualShunting);
|
c.SetBool("ai.trainman", VirtualShunting);
|
||||||
c.SetBool("starter.logmissingvehicles", LogMissingVehicleFiles);
|
c.SetBool("starter.logmissingvehicles", LogMissingVehicleFiles);
|
||||||
|
|
||||||
// Graphics
|
|
||||||
c.Set("gfxrenderer", RenderEngines[Clamp(RenderEngine, 0, RenderEngines.Length - 1)]);
|
c.Set("gfxrenderer", RenderEngines[Clamp(RenderEngine, 0, RenderEngines.Length - 1)]);
|
||||||
c.SetInt("width", Width);
|
c.SetInt("width", Width);
|
||||||
c.SetInt("height", Height);
|
c.SetInt("height", Height);
|
||||||
@@ -654,7 +577,6 @@ public sealed class Settings
|
|||||||
c.SetBool("fullscreenwindowed", FullscreenWindowed);
|
c.SetBool("fullscreenwindowed", FullscreenWindowed);
|
||||||
if (RenderAngleVulkan) c.Set("gfx.angleplatform", "vulkan"); else c.Remove("gfx.angleplatform");
|
if (RenderAngleVulkan) c.Set("gfx.angleplatform", "vulkan"); else c.Remove("gfx.angleplatform");
|
||||||
|
|
||||||
// Physics
|
|
||||||
c.SetInt("splinefidelity", SplineFidelity);
|
c.SetInt("splinefidelity", SplineFidelity);
|
||||||
c.SetBool("fullphysics", FullPhysics);
|
c.SetBool("fullphysics", FullPhysics);
|
||||||
c.SetBool("enabletraction", EnableTraction);
|
c.SetBool("enabletraction", EnableTraction);
|
||||||
@@ -668,7 +590,6 @@ public sealed class Settings
|
|||||||
c.SetDouble("brakestep", BrakeStep);
|
c.SetDouble("brakestep", BrakeStep);
|
||||||
c.SetDouble("brakespeed", BrakeSpeed);
|
c.SetDouble("brakespeed", BrakeSpeed);
|
||||||
|
|
||||||
// Sound
|
|
||||||
c.SetBool("soundenabled", SoundEnabled);
|
c.SetBool("soundenabled", SoundEnabled);
|
||||||
c.SetDouble("sound.volume", Volume / 50.0);
|
c.SetDouble("sound.volume", Volume / 50.0);
|
||||||
c.SetDouble("sound.volume.radio", RadioVolume / 100.0);
|
c.SetDouble("sound.volume.radio", RadioVolume / 100.0);
|
||||||
@@ -676,7 +597,6 @@ public sealed class Settings
|
|||||||
c.SetDouble("sound.volume.positional", PositionalVolume / 100.0);
|
c.SetDouble("sound.volume.positional", PositionalVolume / 100.0);
|
||||||
c.SetDouble("sound.volume.ambient", AmbientVolume / 100.0);
|
c.SetDouble("sound.volume.ambient", AmbientVolume / 100.0);
|
||||||
|
|
||||||
// Advanced
|
|
||||||
c.SetBool("compresstex", CompressTextures);
|
c.SetBool("compresstex", CompressTextures);
|
||||||
c.SetBool("scalespeculars", ScaleSpeculars);
|
c.SetBool("scalespeculars", ScaleSpeculars);
|
||||||
c.SetBool("gfx.usegles", UseGLES);
|
c.SetBool("gfx.usegles", UseGLES);
|
||||||
@@ -688,7 +608,6 @@ public sealed class Settings
|
|||||||
c.SetInt("async.trainThreads", TrainPhysicsThreads);
|
c.SetInt("async.trainThreads", TrainPhysicsThreads);
|
||||||
c.SetBool("starter.ignoreirrelevant", IgnoreIrrelevantTrains);
|
c.SetBool("starter.ignoreirrelevant", IgnoreIrrelevantTrains);
|
||||||
|
|
||||||
// Starter
|
|
||||||
c.SetBool("starter.autoclose", AutoCloseStarter);
|
c.SetBool("starter.autoclose", AutoCloseStarter);
|
||||||
c.SetBool("starter.largethumbnails", LargeThumbnails);
|
c.SetBool("starter.largethumbnails", LargeThumbnails);
|
||||||
c.SetBool("starter.expandtree", AutoExpandSceneryTree);
|
c.SetBool("starter.expandtree", AutoExpandSceneryTree);
|
||||||
@@ -703,7 +622,6 @@ public sealed class Settings
|
|||||||
c.Remove("starter.last.scenery");
|
c.Remove("starter.last.scenery");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── small helpers ──────────────────────────────────────────────────────
|
|
||||||
private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v);
|
private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v);
|
||||||
|
|
||||||
private static int ParseInt(string s, int fallback) =>
|
private static int ParseInt(string s, int fallback) =>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace StarterNG.Classes;
|
|||||||
public enum eDriverType
|
public enum eDriverType
|
||||||
{
|
{
|
||||||
Headdriver,
|
Headdriver,
|
||||||
Reardriver,
|
Reardriver,
|
||||||
Passenger,
|
Passenger,
|
||||||
Nobody
|
Nobody
|
||||||
}
|
}
|
||||||
@@ -25,19 +25,14 @@ public class Trainset
|
|||||||
public string Description;
|
public string Description;
|
||||||
public List<Dynamic> Vehicles;
|
public List<Dynamic> Vehicles;
|
||||||
|
|
||||||
/// <summary>Original .scn text of this trainset block (incl. endtrainset).</summary>
|
|
||||||
public string RawEntry;
|
public string RawEntry;
|
||||||
|
|
||||||
/// <summary>True if the block parsed cleanly; false blocks are exported verbatim.</summary>
|
|
||||||
public bool Parsed;
|
public bool Parsed;
|
||||||
|
|
||||||
/// <summary>True when the block carries the //$decor flag (decoration, not drivable).</summary>
|
|
||||||
public bool Decor;
|
public bool Decor;
|
||||||
|
|
||||||
/// <summary>Loading-screen logo key from <c>//$il</c>.</summary>
|
|
||||||
public string? Logo;
|
public string? Logo;
|
||||||
|
|
||||||
/// <summary>Optional trainset mini from <c>//$it</c>.</summary>
|
|
||||||
public string? Mini;
|
public string? Mini;
|
||||||
|
|
||||||
public Trainset(string trainsetEntry)
|
public Trainset(string trainsetEntry)
|
||||||
@@ -65,8 +60,6 @@ public class Trainset
|
|||||||
.Where(t => !t.StartsWith("//") && !t.StartsWith("/*"))
|
.Where(t => !t.StartsWith("//") && !t.StartsWith("/*"))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// handle specific descriptions
|
|
||||||
// //$o
|
|
||||||
var match = Regex.Match(
|
var match = Regex.Match(
|
||||||
trainsetEntry,
|
trainsetEntry,
|
||||||
@"^\s*//\$o\s*(.*)$",
|
@"^\s*//\$o\s*(.*)$",
|
||||||
@@ -82,15 +75,12 @@ public class Trainset
|
|||||||
var mini = Regex.Match(trainsetEntry, @"^\s*//\$it\b[ \t]*([^\r\n]*)", RegexOptions.Multiline);
|
var mini = Regex.Match(trainsetEntry, @"^\s*//\$it\b[ \t]*([^\r\n]*)", RegexOptions.Multiline);
|
||||||
if (mini.Success)
|
if (mini.Success)
|
||||||
Mini = mini.Groups[1].Value.Trim();
|
Mini = mini.Groups[1].Value.Trim();
|
||||||
|
|
||||||
// parse trainset
|
|
||||||
|
|
||||||
for (int i = 0; i < tokens.Count; i++)
|
for (int i = 0; i < tokens.Count; i++)
|
||||||
{
|
{
|
||||||
if (tokens[i] == "endtrainset")
|
if (tokens[i] == "endtrainset")
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// trainset properties
|
|
||||||
if (tokens[i] == "trainset")
|
if (tokens[i] == "trainset")
|
||||||
{
|
{
|
||||||
this.Name = tokens[++i];
|
this.Name = tokens[++i];
|
||||||
@@ -100,24 +90,22 @@ public class Trainset
|
|||||||
this.OriginalVelocity = this.Velocity;
|
this.OriginalVelocity = this.Velocity;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// skip entire assignments block
|
|
||||||
if (tokens[i] == "assignments")
|
if (tokens[i] == "assignments")
|
||||||
{
|
{
|
||||||
while (i < tokens.Count && tokens[i] != "endassignment")
|
while (i < tokens.Count && tokens[i] != "endassignment")
|
||||||
i++;
|
i++;
|
||||||
i++; // jump over endassignment
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// load vehicles
|
|
||||||
if (tokens[i] == "node")
|
if (tokens[i] == "node")
|
||||||
{
|
{
|
||||||
Dynamic nodeDynamic = new Dynamic();
|
Dynamic nodeDynamic = new Dynamic();
|
||||||
nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
||||||
nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
|
||||||
nodeDynamic.Name = tokens[++i];
|
nodeDynamic.Name = tokens[++i];
|
||||||
i++; // dynamic keyword
|
i++;
|
||||||
nodeDynamic.DataFolder = tokens[++i];
|
nodeDynamic.DataFolder = tokens[++i];
|
||||||
nodeDynamic.SkinFile = tokens[++i];
|
nodeDynamic.SkinFile = tokens[++i];
|
||||||
nodeDynamic.MmdFile = tokens[++i];
|
nodeDynamic.MmdFile = tokens[++i];
|
||||||
@@ -131,37 +119,26 @@ public class Trainset
|
|||||||
_ => eDriverType.Nobody
|
_ => eDriverType.Nobody
|
||||||
};
|
};
|
||||||
|
|
||||||
// couplingdata: numeric bit-mask plus optional ".B/.W/.T" parameter
|
|
||||||
// codes (brake / wheel / thermo settings) kept verbatim.
|
|
||||||
nodeDynamic.Coupling = Coupling.Parse(tokens[++i]);
|
nodeDynamic.Coupling = Coupling.Parse(tokens[++i]);
|
||||||
|
|
||||||
// Everything before enddynamic is the optional, positional tail
|
|
||||||
// documented for node::dynamic:
|
|
||||||
// [Lx] velocity [loadcount [loadtype]] [destination [destination]]
|
|
||||||
var trailing = new List<string>();
|
var trailing = new List<string>();
|
||||||
while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic")
|
while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic")
|
||||||
trailing.Add(tokens[++i]);
|
trailing.Add(tokens[++i]);
|
||||||
nodeDynamic.ReadTrailing(trailing);
|
nodeDynamic.ReadTrailing(trailing);
|
||||||
|
|
||||||
i++; // jump over enddynamic
|
i++;
|
||||||
|
|
||||||
Vehicles.Add(nodeDynamic);
|
Vehicles.Add(nodeDynamic);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The .scn text for this trainset for export. Unparsed blocks are written
|
|
||||||
/// back verbatim; parsed blocks are regenerated from the current vehicles.
|
|
||||||
/// </summary>
|
|
||||||
public string ToSceneryEntry() =>
|
public string ToSceneryEntry() =>
|
||||||
Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry;
|
Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry;
|
||||||
|
|
||||||
public string GetTrainsetEntry()
|
public string GetTrainsetEntry()
|
||||||
{
|
{
|
||||||
// Starter metadata comments (parsed for filters/UI). Pascal's launch rewrite
|
|
||||||
// omits them from $scn; we keep them so a regenerated block stays filterable
|
|
||||||
// if the template is re-opened or compared.
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
if (Decor)
|
if (Decor)
|
||||||
sb.Append("//$decor\n");
|
sb.Append("//$decor\n");
|
||||||
@@ -205,8 +182,7 @@ public class Trainset
|
|||||||
$"{vehicle.Offset.ToString(CultureInfo.InvariantCulture)} " +
|
$"{vehicle.Offset.ToString(CultureInfo.InvariantCulture)} " +
|
||||||
$"{driverType} {vehicle.Coupling}{vehicle.WriteTrailing()} enddynamic\n";
|
$"{driverType} {vehicle.Coupling}{vehicle.WriteTrailing()} enddynamic\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,65 +199,38 @@ public class Dynamic
|
|||||||
public float Offset;
|
public float Offset;
|
||||||
public eDriverType DriverType;
|
public eDriverType DriverType;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Coupling with the next vehicle (the <c>couplingdata</c> field): the bit-mask
|
|
||||||
/// plus any ".B/.W/.T" parameter codes (brake / wheel / thermo settings).
|
|
||||||
/// </summary>
|
|
||||||
public Coupling Coupling = new();
|
public Coupling Coupling = new();
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Low byte of the coupling mask, exposed for the depot's per-bit editor.
|
|
||||||
/// Backed by <see cref="Coupling"/> so the two never diverge.
|
|
||||||
/// </summary>
|
|
||||||
public byte couplingData
|
public byte couplingData
|
||||||
{
|
{
|
||||||
get => (byte)(Coupling.AbsFlags & 0xFF);
|
get => (byte)(Coupling.AbsFlags & 0xFF);
|
||||||
set => Coupling.Flags = value;
|
set => Coupling.Flags = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- optional node::dynamic trailing parameters (all positional, all optional) ---
|
|
||||||
|
|
||||||
/// <summary>"Lx" MaxLoad override token (e.g. "L0"), or null when absent.</summary>
|
|
||||||
public string? MaxLoad;
|
public string? MaxLoad;
|
||||||
|
|
||||||
/// <summary>Whether a per-vehicle velocity token is present / should be written.</summary>
|
|
||||||
public bool HasVelocity;
|
public bool HasVelocity;
|
||||||
|
|
||||||
/// <summary>Per-vehicle starting velocity.</summary>
|
|
||||||
public float Velocity;
|
public float Velocity;
|
||||||
|
|
||||||
/// <summary>Cargo amount (loadcount).</summary>
|
|
||||||
public int LoadCount;
|
public int LoadCount;
|
||||||
|
|
||||||
/// <summary>Cargo type (loadtype) — only meaningful when <see cref="LoadCount"/> > 0.</summary>
|
|
||||||
public string? LoadType;
|
public string? LoadType;
|
||||||
|
|
||||||
/// <summary>Optional cargo destination tokens (rarely used; preserved verbatim).</summary>
|
|
||||||
public List<string> Destinations = new();
|
public List<string> Destinations = new();
|
||||||
|
|
||||||
// --- depot / consist-builder extras (not part of the .scn syntax) ---
|
|
||||||
|
|
||||||
/// <summary>Explicit miniature name (from the vehicle database). Falls back to SkinFile.</summary>
|
|
||||||
public string? MiniName;
|
public string? MiniName;
|
||||||
|
|
||||||
/// <summary>Whether the vehicle is visually reversed in the consist.</summary>
|
|
||||||
public bool Flipped;
|
public bool Flipped;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses the optional positional parameters that follow couplingdata:
|
|
||||||
/// <c>[Lx] velocity [loadcount [loadtype]] [destination [destination]]</c>.
|
|
||||||
/// See https://wiki.eu07.pl/index.php?title=Obiekt_node::dynamic
|
|
||||||
/// </summary>
|
|
||||||
internal void ReadTrailing(List<string> t)
|
internal void ReadTrailing(List<string> t)
|
||||||
{
|
{
|
||||||
int p = 0;
|
int p = 0;
|
||||||
|
|
||||||
// Lx — MaxLoad override (e.g. "L0"); only when it genuinely looks like one.
|
|
||||||
if (p < t.Count && (t[p][0] == 'L' || t[p][0] == 'l')
|
if (p < t.Count && (t[p][0] == 'L' || t[p][0] == 'l')
|
||||||
&& t[p].Length > 1 && char.IsDigit(t[p][1]))
|
&& t[p].Length > 1 && char.IsDigit(t[p][1]))
|
||||||
MaxLoad = t[p++];
|
MaxLoad = t[p++];
|
||||||
|
|
||||||
// velocity — the first plain number after the coupling
|
|
||||||
if (p < t.Count && float.TryParse(t[p], NumberStyles.Float,
|
if (p < t.Count && float.TryParse(t[p], NumberStyles.Float,
|
||||||
CultureInfo.InvariantCulture, out float v))
|
CultureInfo.InvariantCulture, out float v))
|
||||||
{
|
{
|
||||||
@@ -290,7 +239,6 @@ public class Dynamic
|
|||||||
p++;
|
p++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadcount [loadtype] — loadtype is present only when loadcount > 0
|
|
||||||
if (p < t.Count && int.TryParse(t[p], NumberStyles.Integer,
|
if (p < t.Count && int.TryParse(t[p], NumberStyles.Integer,
|
||||||
CultureInfo.InvariantCulture, out int lc))
|
CultureInfo.InvariantCulture, out int lc))
|
||||||
{
|
{
|
||||||
@@ -300,16 +248,10 @@ public class Dynamic
|
|||||||
LoadType = t[p++];
|
LoadType = t[p++];
|
||||||
}
|
}
|
||||||
|
|
||||||
// remaining tokens: optional destinations, kept verbatim
|
|
||||||
while (p < t.Count)
|
while (p < t.Count)
|
||||||
Destinations.Add(t[p++]);
|
Destinations.Add(t[p++]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Regenerates the trailing parameter string (each present token prefixed with
|
|
||||||
/// a space), the inverse of <see cref="ReadTrailing"/>. A loadcount of 0 is
|
|
||||||
/// omitted, matching the canonical trainset entries the simulator expects.
|
|
||||||
/// </summary>
|
|
||||||
internal string WriteTrailing()
|
internal string WriteTrailing()
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
@@ -328,7 +270,6 @@ public class Dynamic
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Deep copy, used when importing a trainset into the editable consist.</summary>
|
|
||||||
public Dynamic Clone() => new Dynamic
|
public Dynamic Clone() => new Dynamic
|
||||||
{
|
{
|
||||||
RangeMax = RangeMax,
|
RangeMax = RangeMax,
|
||||||
@@ -351,7 +292,6 @@ public class Dynamic
|
|||||||
Flipped = Flipped
|
Flipped = Flipped
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Pascal <c>PrepareNode(..., TrainSet=False)</c>.</summary>
|
|
||||||
public string ToLooseNode()
|
public string ToLooseNode()
|
||||||
{
|
{
|
||||||
string driver = DriverType switch
|
string driver = DriverType switch
|
||||||
@@ -372,7 +312,6 @@ public class Dynamic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One entry in the depot consist: a single vehicle or a locked multi-car unit.</summary>
|
|
||||||
public sealed class ConsistItem
|
public sealed class ConsistItem
|
||||||
{
|
{
|
||||||
public List<Dynamic> Cars { get; set; } = new();
|
public List<Dynamic> Cars { get; set; } = new();
|
||||||
@@ -381,39 +320,24 @@ public sealed class ConsistItem
|
|||||||
public eDriverType Driver { get; set; } = eDriverType.Nobody;
|
public eDriverType Driver { get; set; } = eDriverType.Nobody;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The <c>couplingdata</c> value joining a vehicle to the next one: a bit-mask of
|
|
||||||
/// the active coupling types, optionally followed by ".B/.W/.T" parameter codes.
|
|
||||||
/// A negative mask means the coupling is locked (cannot be uncoupled in-sim); the
|
|
||||||
/// simulator takes its absolute value before testing the individual bits.
|
|
||||||
/// See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow
|
|
||||||
/// </summary>
|
|
||||||
public sealed class Coupling
|
public sealed class Coupling
|
||||||
{
|
{
|
||||||
// Mask bit values, per the wiki.
|
|
||||||
public const int Mechanical = 1; // mechanical link
|
|
||||||
public const int BrakePipe = 2; // pneumatic 5 atm (brakes)
|
|
||||||
public const int ControlMU = 4; // multiple-unit control
|
|
||||||
public const int HighVoltage = 8; // high voltage
|
|
||||||
public const int Gangway = 16; // passage between vehicles
|
|
||||||
public const int AuxPneumatic = 32; // auxiliary pneumatic 8 atm
|
|
||||||
public const int Heating = 64; // heating
|
|
||||||
public const int WorkshopLock = 128; // workshop coupling (uncouple lock)
|
|
||||||
|
|
||||||
/// <summary>Signed mask exactly as written; negative = locked.</summary>
|
public const int Mechanical = 1;
|
||||||
|
public const int BrakePipe = 2;
|
||||||
|
public const int ControlMU = 4;
|
||||||
|
public const int HighVoltage = 8;
|
||||||
|
public const int Gangway = 16;
|
||||||
|
public const int AuxPneumatic = 32;
|
||||||
|
public const int Heating = 64;
|
||||||
|
public const int WorkshopLock = 128;
|
||||||
|
|
||||||
public int Flags;
|
public int Flags;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Textual parameter codes that follow the mask after a '.', in order
|
|
||||||
/// (e.g. "BR", "WH25F5"). Preserved verbatim so brake / wheel / thermo
|
|
||||||
/// settings survive a round-trip even when the launcher does not edit them.
|
|
||||||
/// </summary>
|
|
||||||
public List<string> Parameters = new();
|
public List<string> Parameters = new();
|
||||||
|
|
||||||
/// <summary>Mask with the lock sign removed (what the simulator tests bits on).</summary>
|
|
||||||
public int AbsFlags => Flags < 0 ? -Flags : Flags;
|
public int AbsFlags => Flags < 0 ? -Flags : Flags;
|
||||||
|
|
||||||
/// <summary>True when the coupling is marked permanent (negative mask).</summary>
|
|
||||||
public bool Locked
|
public bool Locked
|
||||||
{
|
{
|
||||||
get => Flags < 0;
|
get => Flags < 0;
|
||||||
@@ -422,7 +346,6 @@ public sealed class Coupling
|
|||||||
|
|
||||||
public bool Has(int bit) => (AbsFlags & bit) != 0;
|
public bool Has(int bit) => (AbsFlags & bit) != 0;
|
||||||
|
|
||||||
/// <summary>Sets or clears a mask bit, preserving the lock sign.</summary>
|
|
||||||
public void Set(int bit, bool on)
|
public void Set(int bit, bool on)
|
||||||
{
|
{
|
||||||
int abs = on ? (AbsFlags | bit) : (AbsFlags & ~bit);
|
int abs = on ? (AbsFlags | bit) : (AbsFlags & ~bit);
|
||||||
@@ -444,12 +367,10 @@ public sealed class Coupling
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The brake-rack setting carried in the "B" parameter, or null.</summary>
|
|
||||||
public BrakeSetting? GetBrake() =>
|
public BrakeSetting? GetBrake() =>
|
||||||
BrakeSetting.FromParameter(
|
BrakeSetting.FromParameter(
|
||||||
Parameters.FirstOrDefault(p => p.StartsWith("B", StringComparison.Ordinal)));
|
Parameters.FirstOrDefault(p => p.StartsWith("B", StringComparison.Ordinal)));
|
||||||
|
|
||||||
/// <summary>Replaces (or clears, when null) the "B" brake parameter.</summary>
|
|
||||||
public void SetBrake(BrakeSetting? brake)
|
public void SetBrake(BrakeSetting? brake)
|
||||||
{
|
{
|
||||||
Parameters.RemoveAll(p => p.StartsWith("B", StringComparison.Ordinal));
|
Parameters.RemoveAll(p => p.StartsWith("B", StringComparison.Ordinal));
|
||||||
@@ -457,12 +378,10 @@ public sealed class Coupling
|
|||||||
Parameters.Insert(0, brake.ToParameter());
|
Parameters.Insert(0, brake.ToParameter());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The wheel/damage setting carried in the "W" parameter, or null.</summary>
|
|
||||||
public WheelSettings? GetWheels() =>
|
public WheelSettings? GetWheels() =>
|
||||||
WheelSettings.FromParameter(
|
WheelSettings.FromParameter(
|
||||||
Parameters.FirstOrDefault(p => p.StartsWith("W", StringComparison.Ordinal)));
|
Parameters.FirstOrDefault(p => p.StartsWith("W", StringComparison.Ordinal)));
|
||||||
|
|
||||||
/// <summary>Replaces (or clears, when empty/null) the "W" wheel parameter.</summary>
|
|
||||||
public void SetWheels(WheelSettings? wheels)
|
public void SetWheels(WheelSettings? wheels)
|
||||||
{
|
{
|
||||||
Parameters.RemoveAll(p => p.StartsWith("W", StringComparison.Ordinal));
|
Parameters.RemoveAll(p => p.StartsWith("W", StringComparison.Ordinal));
|
||||||
@@ -470,7 +389,6 @@ public sealed class Coupling
|
|||||||
Parameters.Add(wheels.ToParameter());
|
Parameters.Add(wheels.ToParameter());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Coolant at ambient temperature (<c>.TA</c>), like Pascal ThermoDynamic.</summary>
|
|
||||||
public bool ThermoDynamic
|
public bool ThermoDynamic
|
||||||
{
|
{
|
||||||
get => Parameters.Exists(p => p.Equals("TA", StringComparison.OrdinalIgnoreCase));
|
get => Parameters.Exists(p => p.Equals("TA", StringComparison.OrdinalIgnoreCase));
|
||||||
@@ -487,7 +405,6 @@ public sealed class Coupling
|
|||||||
Parameters = new List<string>(Parameters)
|
Parameters = new List<string>(Parameters)
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Serialises back to "<flags>[.param[.param…]]".</summary>
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
@@ -498,23 +415,14 @@ public sealed class Coupling
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A vehicle's brake-rack setting, carried in the "B" parameter of its coupling
|
|
||||||
/// field: <c>B<mode>[<load>][<switch>]</c> (e.g. "BP", "BRA", "BG1").
|
|
||||||
/// See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow
|
|
||||||
/// </summary>
|
|
||||||
public sealed class BrakeSetting
|
public sealed class BrakeSetting
|
||||||
{
|
{
|
||||||
/// <summary>Brake modes, longest first so "R+Mg" wins over "R" while parsing.</summary>
|
|
||||||
public static readonly string[] Modes = { "R+Mg", "G", "P", "R", "Q", "O", "A" };
|
public static readonly string[] Modes = { "R+Mg", "G", "P", "R", "Q", "O", "A" };
|
||||||
|
|
||||||
/// <summary>Brake mode: G (freight), P (passenger), R (express), R+Mg, Q, O (off), A (auto).</summary>
|
|
||||||
public string Mode = "P";
|
public string Mode = "P";
|
||||||
|
|
||||||
/// <summary>Load adaptation: T (empty), H (medium), F (loaded), A (auto), or null.</summary>
|
|
||||||
public string? Load;
|
public string? Load;
|
||||||
|
|
||||||
/// <summary>Brake on/off switch: 0 (off), 1 (off ~10%), A (on), or null.</summary>
|
|
||||||
public string? Switch;
|
public string? Switch;
|
||||||
|
|
||||||
public static BrakeSetting? FromParameter(string? param)
|
public static BrakeSetting? FromParameter(string? param)
|
||||||
@@ -539,23 +447,14 @@ public sealed class BrakeSetting
|
|||||||
public string ToParameter() => "B" + Mode + (Load ?? "") + (Switch ?? "");
|
public string ToParameter() => "B" + Mode + (Load ?? "") + (Switch ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Wheel-damage settings carried in the "W" parameter of a vehicle's coupling
|
|
||||||
/// field: <c>W[H<sway%>][F<flat mm>][R<random flat mm>][P<flat prob%>]</c>
|
|
||||||
/// (e.g. "WH25F5R10P8"). See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow
|
|
||||||
/// </summary>
|
|
||||||
public sealed class WheelSettings
|
public sealed class WheelSettings
|
||||||
{
|
{
|
||||||
/// <summary>H — sway ("wężykowanie") probability, %.</summary>
|
|
||||||
public int Sway;
|
public int Sway;
|
||||||
|
|
||||||
/// <summary>F — flat spot ("podkucie") of this size, mm.</summary>
|
|
||||||
public int Flatness;
|
public int Flatness;
|
||||||
|
|
||||||
/// <summary>R — additional random flat spot, 0..x mm.</summary>
|
|
||||||
public int FlatnessRand;
|
public int FlatnessRand;
|
||||||
|
|
||||||
/// <summary>P — flat-spot probability, % (guaranteed when omitted).</summary>
|
|
||||||
public int FlatnessProb;
|
public int FlatnessProb;
|
||||||
|
|
||||||
public bool IsEmpty => Sway <= 0 && Flatness <= 0 && FlatnessRand <= 0 && FlatnessProb <= 0;
|
public bool IsEmpty => Sway <= 0 && Flatness <= 0 && FlatnessRand <= 0 && FlatnessProb <= 0;
|
||||||
@@ -594,4 +493,4 @@ public sealed class WheelSettings
|
|||||||
if (FlatnessProb > 0) sb.Append('P').Append(FlatnessProb);
|
if (FlatnessProb > 0) sb.Append('P').Append(FlatnessProb);
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,6 @@ using System.Text.Json.Serialization.Metadata;
|
|||||||
|
|
||||||
namespace StarterNG.Classes;
|
namespace StarterNG.Classes;
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Vehicle JSON database (databases/vehicles/*.json)
|
|
||||||
// Format documented in vehicleEntryDoc.md (schema_version 1).
|
|
||||||
// =====================================================================
|
|
||||||
|
|
||||||
/// <summary>Root object of a single vehicle JSON file.</summary>
|
|
||||||
public class VehicleEntry
|
public class VehicleEntry
|
||||||
{
|
{
|
||||||
[JsonPropertyName("uuid")] public string? Uuid { get; set; }
|
[JsonPropertyName("uuid")] public string? Uuid { get; set; }
|
||||||
@@ -24,13 +18,11 @@ public class VehicleEntry
|
|||||||
[JsonPropertyName("unknown")] public List<string> Unknown { get; set; } = new();
|
[JsonPropertyName("unknown")] public List<string> Unknown { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Merged database root (databases/vehicles/vehicles.json).</summary>
|
|
||||||
public class VehicleEntryCollection
|
public class VehicleEntryCollection
|
||||||
{
|
{
|
||||||
[JsonPropertyName("vehicles")] public List<VehicleEntry> Vehicles { get; set; } = new();
|
[JsonPropertyName("vehicles")] public List<VehicleEntry> Vehicles { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Legacy header / vehicle category group.</summary>
|
|
||||||
public class VehicleGroup
|
public class VehicleGroup
|
||||||
{
|
{
|
||||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||||
@@ -40,7 +32,6 @@ public class VehicleGroup
|
|||||||
[JsonPropertyName("implicit")] public bool Implicit { get; set; }
|
[JsonPropertyName("implicit")] public bool Implicit { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A single .mat skin entry.</summary>
|
|
||||||
public class VehicleTexture
|
public class VehicleTexture
|
||||||
{
|
{
|
||||||
[JsonPropertyName("uuid")] public string? Uuid { get; set; }
|
[JsonPropertyName("uuid")] public string? Uuid { get; set; }
|
||||||
@@ -54,24 +45,15 @@ public class VehicleTexture
|
|||||||
[JsonPropertyName("aliases")] public List<VehicleAlias> Aliases { get; set; } = new();
|
[JsonPropertyName("aliases")] public List<VehicleAlias> Aliases { get; set; } = new();
|
||||||
[JsonPropertyName("meta")] public VehicleMeta? Meta { get; set; }
|
[JsonPropertyName("meta")] public VehicleMeta? Meta { get; set; }
|
||||||
|
|
||||||
/// <summary>Full skin path = directory + skinfile.</summary>
|
|
||||||
[JsonIgnore] public string FullPath => Directory + Skinfile;
|
[JsonIgnore] public string FullPath => Directory + Skinfile;
|
||||||
|
|
||||||
// ── cached lookups, populated once after load (VehicleDatabase.BuildTextureIndex) ──
|
|
||||||
// These mirror the depot's ClassOf / CategoryOf so the search/filter hot path
|
|
||||||
// never recomputes them (no per-keystroke dictionary lookups over every texture).
|
|
||||||
|
|
||||||
/// <summary>Group's "mini" (or this texture's mini_ref) — the vehicle class. Never null.</summary>
|
|
||||||
[JsonIgnore] public string ResolvedClass { get; internal set; } = "";
|
[JsonIgnore] public string ResolvedClass { get; internal set; } = "";
|
||||||
|
|
||||||
/// <summary>Group's "category" letter, or null when the group is unknown.</summary>
|
|
||||||
[JsonIgnore] public string? ResolvedCategory { get; internal set; }
|
[JsonIgnore] public string? ResolvedCategory { get; internal set; }
|
||||||
|
|
||||||
/// <summary>True when the texture's group is marked archival (<c>archived</c> in JSON).</summary>
|
|
||||||
[JsonIgnore] public bool ResolvedArchived { get; internal set; }
|
[JsonIgnore] public bool ResolvedArchived { get; internal set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Alternate mapping from a malformed multi-= legacy line.</summary>
|
|
||||||
public class VehicleAlias
|
public class VehicleAlias
|
||||||
{
|
{
|
||||||
[JsonPropertyName("model")] public string? Model { get; set; }
|
[JsonPropertyName("model")] public string? Model { get; set; }
|
||||||
@@ -80,7 +62,6 @@ public class VehicleAlias
|
|||||||
[JsonPropertyName("texture_mini")] public string? TextureMini { get; set; }
|
[JsonPropertyName("texture_mini")] public string? TextureMini { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Parsed metadata from the legacy // comment section.</summary>
|
|
||||||
public class VehicleMeta
|
public class VehicleMeta
|
||||||
{
|
{
|
||||||
[JsonPropertyName("raw")] public string? Raw { get; set; }
|
[JsonPropertyName("raw")] public string? Raw { get; set; }
|
||||||
@@ -95,29 +76,18 @@ public class VehicleMeta
|
|||||||
[JsonPropertyName("extra")] public List<string> Extra { get; set; } = new();
|
[JsonPropertyName("extra")] public List<string> Extra { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Aggregated, in-memory view over every vehicle JSON file found in
|
|
||||||
/// databases/vehicles/. Provides lookup helpers for the depot UI.
|
|
||||||
/// </summary>
|
|
||||||
public class VehicleDatabase
|
public class VehicleDatabase
|
||||||
{
|
{
|
||||||
public List<VehicleTexture> Textures { get; } = new();
|
public List<VehicleTexture> Textures { get; } = new();
|
||||||
public Dictionary<string, VehicleGroup> GroupsById { get; } = new();
|
public Dictionary<string, VehicleGroup> GroupsById { get; } = new();
|
||||||
public List<VehicleSet> Sets { get; } = new();
|
public List<VehicleSet> Sets { get; } = new();
|
||||||
|
|
||||||
/// <summary>Texture-uuid -> automatic-consist set that contains it.</summary>
|
|
||||||
public Dictionary<string, VehicleSet> SetByTextureUuid { get; } = new();
|
public Dictionary<string, VehicleSet> SetByTextureUuid { get; } = new();
|
||||||
|
|
||||||
/// <summary>Texture-uuid -> texture (includes wrecks, so set refs resolve).</summary>
|
|
||||||
public Dictionary<string, VehicleTexture> TextureByUuid { get; } = new();
|
public Dictionary<string, VehicleTexture> TextureByUuid { get; } = new();
|
||||||
|
|
||||||
/// <summary>skinfile (without extension, lower-case) -> texture.</summary>
|
|
||||||
public Dictionary<string, VehicleTexture> TextureBySkin { get; } = new();
|
public Dictionary<string, VehicleTexture> TextureBySkin { get; } = new();
|
||||||
|
|
||||||
// System.Text.Json options matching the previous Newtonsoft leniency: property
|
|
||||||
// names matched case-insensitively, // and /* */ comments skipped, and trailing
|
|
||||||
// commas allowed. Unknown members are ignored by default. The source-generated
|
|
||||||
// VehicleJsonContext resolver keeps deserialization AOT/trim-safe (no reflection).
|
|
||||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
@@ -126,11 +96,6 @@ public class VehicleDatabase
|
|||||||
TypeInfoResolver = VehicleJsonContext.Default,
|
TypeInfoResolver = VehicleJsonContext.Default,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Loads the database in one call. When a merged vehicles.json is present
|
|
||||||
/// it is used, otherwise every per-vehicle *.json file is read.
|
|
||||||
/// Never throws: unreadable files are skipped.
|
|
||||||
/// </summary>
|
|
||||||
public void Load(string directory = "databases/vehicles/")
|
public void Load(string directory = "databases/vehicles/")
|
||||||
{
|
{
|
||||||
BeginLoad();
|
BeginLoad();
|
||||||
@@ -139,7 +104,6 @@ public class VehicleDatabase
|
|||||||
EndLoad();
|
EndLoad();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Resets the aggregate before an incremental load.</summary>
|
|
||||||
public void BeginLoad()
|
public void BeginLoad()
|
||||||
{
|
{
|
||||||
Textures.Clear();
|
Textures.Clear();
|
||||||
@@ -150,17 +114,12 @@ public class VehicleDatabase
|
|||||||
TextureBySkin.Clear();
|
TextureBySkin.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Finalises an incremental load (builds lookup indexes).</summary>
|
|
||||||
public void EndLoad()
|
public void EndLoad()
|
||||||
{
|
{
|
||||||
BuildSetIndex();
|
BuildSetIndex();
|
||||||
BuildTextureIndex();
|
BuildTextureIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Files that make up the database: the merged vehicles.json if present,
|
|
||||||
/// otherwise every per-vehicle *.json. Used to drive load progress.
|
|
||||||
/// </summary>
|
|
||||||
public static List<string> EnumerateFiles(string directory = "databases/vehicles/")
|
public static List<string> EnumerateFiles(string directory = "databases/vehicles/")
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(directory))
|
if (!Directory.Exists(directory))
|
||||||
@@ -173,7 +132,6 @@ public class VehicleDatabase
|
|||||||
return Directory.GetFiles(directory, "*.json").ToList();
|
return Directory.GetFiles(directory, "*.json").ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ingests a single database file (merged or per-vehicle).</summary>
|
|
||||||
public void LoadFile(string file)
|
public void LoadFile(string file)
|
||||||
{
|
{
|
||||||
if (string.Equals(Path.GetFileName(file), "vehicles.json", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(Path.GetFileName(file), "vehicles.json", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -195,7 +153,7 @@ public class VehicleDatabase
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// ignore malformed merged database
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +169,7 @@ public class VehicleDatabase
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// ignore malformed single-vehicle file
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,13 +183,12 @@ public class VehicleDatabase
|
|||||||
|
|
||||||
foreach (var texture in entry.Textures)
|
foreach (var texture in entry.Textures)
|
||||||
{
|
{
|
||||||
// index every texture (wrecks too) so set / skin references resolve
|
|
||||||
if (!string.IsNullOrEmpty(texture.Uuid))
|
if (!string.IsNullOrEmpty(texture.Uuid))
|
||||||
TextureByUuid[texture.Uuid!] = texture;
|
TextureByUuid[texture.Uuid!] = texture;
|
||||||
if (!string.IsNullOrEmpty(texture.Skinfile))
|
if (!string.IsNullOrEmpty(texture.Skinfile))
|
||||||
TextureBySkin.TryAdd(Path.GetFileNameWithoutExtension(texture.Skinfile).ToLowerInvariant(), texture);
|
TextureBySkin.TryAdd(Path.GetFileNameWithoutExtension(texture.Skinfile).ToLowerInvariant(), texture);
|
||||||
|
|
||||||
// skip wrecks from the standard browser list
|
|
||||||
if (texture.Wreck) continue;
|
if (texture.Wreck) continue;
|
||||||
Textures.Add(texture);
|
Textures.Add(texture);
|
||||||
}
|
}
|
||||||
@@ -239,10 +196,6 @@ public class VehicleDatabase
|
|||||||
Sets.AddRange(entry.Sets);
|
Sets.AddRange(entry.Sets);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// If the texture belongs to an automatic-consist set, returns all of that
|
|
||||||
/// set's textures in their defined order; otherwise null.
|
|
||||||
/// </summary>
|
|
||||||
public List<VehicleTexture>? ResolveSet(VehicleTexture texture)
|
public List<VehicleTexture>? ResolveSet(VehicleTexture texture)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(texture.Uuid)) return null;
|
if (string.IsNullOrEmpty(texture.Uuid)) return null;
|
||||||
@@ -271,9 +224,6 @@ public class VehicleDatabase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolves each texture's class (group mini / mini_ref) and category letter
|
|
||||||
// once, after all groups are loaded, so the depot reads cached fields instead
|
|
||||||
// of doing a GroupsById lookup per texture on every search keystroke.
|
|
||||||
private void BuildTextureIndex()
|
private void BuildTextureIndex()
|
||||||
{
|
{
|
||||||
foreach (var t in Textures)
|
foreach (var t in Textures)
|
||||||
@@ -288,9 +238,6 @@ public class VehicleDatabase
|
|||||||
|
|
||||||
string? category = g?.Category;
|
string? category = g?.Category;
|
||||||
|
|
||||||
// A "*" category is a wildcard: the actual category is the first letter
|
|
||||||
// of the mini (upper-cased, like the original Starter's UpCase(mini[1])
|
|
||||||
// wagon-typing), e.g. a "Bdhpumn" mini -> category "B".
|
|
||||||
if (category == "*")
|
if (category == "*")
|
||||||
{
|
{
|
||||||
string source = !string.IsNullOrEmpty(mini) ? mini : (t.TextureMini ?? "");
|
string source = !string.IsNullOrEmpty(mini) ? mini : (t.TextureMini ?? "");
|
||||||
@@ -304,10 +251,6 @@ public class VehicleDatabase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when the texture is a non-leading member of an auto-consist set
|
|
||||||
/// (subsequent multi-unit cars that the depot browser should hide).
|
|
||||||
/// </summary>
|
|
||||||
public bool IsSetFollower(VehicleTexture texture)
|
public bool IsSetFollower(VehicleTexture texture)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(texture.Uuid)) return false;
|
if (string.IsNullOrEmpty(texture.Uuid)) return false;
|
||||||
@@ -320,10 +263,6 @@ public class VehicleDatabase
|
|||||||
!string.Equals(lead, texture.Uuid, StringComparison.OrdinalIgnoreCase);
|
!string.Equals(lead, texture.Uuid, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Miniature name for a texture: the texture_mini property, but if no .bmp
|
|
||||||
/// for it exists, falls back to the mini of the group it belongs to.
|
|
||||||
/// </summary>
|
|
||||||
public string? ResolveMiniName(VehicleTexture texture)
|
public string? ResolveMiniName(VehicleTexture texture)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(texture.TextureMini) && MiniPath(texture.TextureMini) != null)
|
if (!string.IsNullOrEmpty(texture.TextureMini) && MiniPath(texture.TextureMini) != null)
|
||||||
@@ -336,7 +275,6 @@ public class VehicleDatabase
|
|||||||
return texture.TextureMini;
|
return texture.TextureMini;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Resolved mini for a skin file (matched case-insensitively), or null.</summary>
|
|
||||||
public string? MiniForSkin(string? skinFile)
|
public string? MiniForSkin(string? skinFile)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(skinFile)) return null;
|
if (string.IsNullOrEmpty(skinFile)) return null;
|
||||||
@@ -344,7 +282,6 @@ public class VehicleDatabase
|
|||||||
return TextureBySkin.TryGetValue(key, out var tex) ? ResolveMiniName(tex) : null;
|
return TextureBySkin.TryGetValue(key, out var tex) ? ResolveMiniName(tex) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The texture for a skin file (matched case-insensitively), or null.</summary>
|
|
||||||
public VehicleTexture? TextureForSkin(string? skinFile)
|
public VehicleTexture? TextureForSkin(string? skinFile)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(skinFile)) return null;
|
if (string.IsNullOrEmpty(skinFile)) return null;
|
||||||
@@ -352,7 +289,6 @@ public class VehicleDatabase
|
|||||||
return TextureBySkin.TryGetValue(key, out var tex) ? tex : null;
|
return TextureBySkin.TryGetValue(key, out var tex) ? tex : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Lines for starter/bledy.txt (Pascal chLogExt / missing assets).</summary>
|
|
||||||
public List<string> CollectMissingAssetLines()
|
public List<string> CollectMissingAssetLines()
|
||||||
{
|
{
|
||||||
var lines = new List<string>();
|
var lines = new List<string>();
|
||||||
@@ -377,7 +313,6 @@ public class VehicleDatabase
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Header label for the group a texture belongs to.</summary>
|
|
||||||
public string GroupHeader(string? groupId)
|
public string GroupHeader(string? groupId)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(groupId) && GroupsById.TryGetValue(groupId, out var grp))
|
if (!string.IsNullOrEmpty(groupId) && GroupsById.TryGetValue(groupId, out var grp))
|
||||||
@@ -388,21 +323,11 @@ public class VehicleDatabase
|
|||||||
return groupId ?? "";
|
return groupId ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case-insensitive index of mini .bmp files (built once).
|
|
||||||
private static Dictionary<string, string>? _miniIndex;
|
private static Dictionary<string, string>? _miniIndex;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the miniature .bmp index up front (called from the startup load,
|
|
||||||
/// behind the splash) so the first thumbnail render never stalls the UI
|
|
||||||
/// thread scanning textures/mini/. No-op if it was already built.
|
|
||||||
/// </summary>
|
|
||||||
public static void PreloadMiniIndex(string miniDir = "textures/mini/") =>
|
public static void PreloadMiniIndex(string miniDir = "textures/mini/") =>
|
||||||
_miniIndex ??= BuildMiniIndex(miniDir);
|
_miniIndex ??= BuildMiniIndex(miniDir);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resolves a miniature .bmp path under textures/mini/ case-insensitively,
|
|
||||||
/// or null if missing. The directory is indexed once and reused.
|
|
||||||
/// </summary>
|
|
||||||
public static string? MiniPath(string? miniName, string miniDir = "textures/mini/")
|
public static string? MiniPath(string? miniName, string miniDir = "textures/mini/")
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(miniName)) return null;
|
if (string.IsNullOrEmpty(miniName)) return null;
|
||||||
@@ -423,7 +348,6 @@ public class VehicleDatabase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Automatic consist definition (legacy ^x grouping).</summary>
|
|
||||||
public class VehicleSet
|
public class VehicleSet
|
||||||
{
|
{
|
||||||
[JsonPropertyName("uuid")] public string? Uuid { get; set; }
|
[JsonPropertyName("uuid")] public string? Uuid { get; set; }
|
||||||
@@ -432,9 +356,6 @@ public class VehicleSet
|
|||||||
[JsonPropertyName("texture_refs")] public List<string> TextureRefs { get; set; } = new();
|
[JsonPropertyName("texture_refs")] public List<string> TextureRefs { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source-generated JSON metadata — makes deserialization AOT/trim-safe by avoiding
|
|
||||||
// runtime reflection over the model types. Nested types (VehicleGroup, VehicleTexture,
|
|
||||||
// VehicleSet, VehicleAlias, VehicleMeta) are pulled in automatically by the generator.
|
|
||||||
[JsonSerializable(typeof(VehicleEntry))]
|
[JsonSerializable(typeof(VehicleEntry))]
|
||||||
[JsonSerializable(typeof(VehicleEntryCollection))]
|
[JsonSerializable(typeof(VehicleEntryCollection))]
|
||||||
internal partial class VehicleJsonContext : JsonSerializerContext
|
internal partial class VehicleJsonContext : JsonSerializerContext
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ using StarterNG.Classes;
|
|||||||
|
|
||||||
namespace StarterNG.Domain;
|
namespace StarterNG.Domain;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pascal <c>TI18n</c> / <c>TrToken</c>: scenery strings starting with <c>@</c>
|
|
||||||
/// resolve against <c>scenery/i18n/{id}_{lang}.json</c>.
|
|
||||||
/// </summary>
|
|
||||||
public static class SceneryI18n
|
public static class SceneryI18n
|
||||||
{
|
{
|
||||||
private static readonly Dictionary<string, string> Map =
|
private static readonly Dictionary<string, string> Map =
|
||||||
@@ -17,7 +13,6 @@ public static class SceneryI18n
|
|||||||
|
|
||||||
public static void Clear() => Map.Clear();
|
public static void Clear() => Map.Clear();
|
||||||
|
|
||||||
/// <param name="langCode">Short UI language code (pl/en/cz/hu/ru).</param>
|
|
||||||
public static void LoadFor(Scenery scenery, string langCode = "en")
|
public static void LoadFor(Scenery scenery, string langCode = "en")
|
||||||
{
|
{
|
||||||
Map.Clear();
|
Map.Clear();
|
||||||
@@ -26,13 +21,11 @@ public static class SceneryI18n
|
|||||||
string lang = NormalizeLang(langCode);
|
string lang = NormalizeLang(langCode);
|
||||||
string i18nDir = Path.Combine(scnDir, "i18n");
|
string i18nDir = Path.Combine(scnDir, "i18n");
|
||||||
|
|
||||||
// Fallback first (en), then active language overlays — same as Pascal.
|
|
||||||
TryLoad(Path.Combine(i18nDir, $"{id}_en.json"));
|
TryLoad(Path.Combine(i18nDir, $"{id}_en.json"));
|
||||||
if (!string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase))
|
||||||
TryLoad(Path.Combine(i18nDir, $"{id}_{lang}.json"));
|
TryLoad(Path.Combine(i18nDir, $"{id}_{lang}.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Translate <c>@token</c> / <c>@nested.key</c>; plain text passes through.</summary>
|
|
||||||
public static string T(string? text)
|
public static string T(string? text)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text)) return text ?? "";
|
if (string.IsNullOrWhiteSpace(text)) return text ?? "";
|
||||||
@@ -51,7 +44,7 @@ public static class SceneryI18n
|
|||||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||||
Flatten("", doc.RootElement);
|
Flatten("", doc.RootElement);
|
||||||
}
|
}
|
||||||
catch { /* ignore bad i18n files */ }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void Flatten(string prefix, JsonElement obj)
|
private static void Flatten(string prefix, JsonElement obj)
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ using StarterNG.Classes;
|
|||||||
|
|
||||||
namespace StarterNG.Domain;
|
namespace StarterNG.Domain;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Skin shuffle for the depot consist — same idea as Pascal <c>TTexRandomizer</c>:
|
|
||||||
/// pick another texture of a similar mini/class (expanded by <c>starter/reguly.txt</c>),
|
|
||||||
/// optionally clamped by operator and revision year.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class TexRandomizer
|
public sealed class TexRandomizer
|
||||||
{
|
{
|
||||||
private readonly VehicleDatabase _db;
|
private readonly VehicleDatabase _db;
|
||||||
@@ -60,13 +55,10 @@ public sealed class TexRandomizer
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// bad rules file — keep going with an empty stock
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reassigns skins on <paramref name="consist"/> in place. Returns how many units changed.
|
|
||||||
/// </summary>
|
|
||||||
public int Apply(IList<ConsistItem> consist, Func<VehicleTexture, Dynamic> makeCar)
|
public int Apply(IList<ConsistItem> consist, Func<VehicleTexture, Dynamic> makeCar)
|
||||||
{
|
{
|
||||||
int changed = 0;
|
int changed = 0;
|
||||||
@@ -82,7 +74,6 @@ public sealed class TexRandomizer
|
|||||||
string? mini = ResolveFilterMini(item, i, consist, current);
|
string? mini = ResolveFilterMini(item, i, consist, current);
|
||||||
if (string.IsNullOrEmpty(mini)) continue;
|
if (string.IsNullOrEmpty(mini)) continue;
|
||||||
|
|
||||||
// Pascal skips units that have a revision filter on but no parseable year.
|
|
||||||
int? revYear = null;
|
int? revYear = null;
|
||||||
if (RevisionTolerance > 0)
|
if (RevisionTolerance > 0)
|
||||||
{
|
{
|
||||||
@@ -92,7 +83,7 @@ public sealed class TexRandomizer
|
|||||||
|
|
||||||
string? owner = null;
|
string? owner = null;
|
||||||
string? cat = current?.ResolvedCategory;
|
string? cat = current?.ResolvedCategory;
|
||||||
// Keep operator match for non-EMU / non-railbus (Pascal skips EZT/SZYNOBUS).
|
|
||||||
if (current != null && cat is not ("z" or "a"))
|
if (current != null && cat is not ("z" or "a"))
|
||||||
owner = current.Meta?.Operator;
|
owner = current.Meta?.Operator;
|
||||||
|
|
||||||
@@ -134,7 +125,6 @@ public sealed class TexRandomizer
|
|||||||
return string.IsNullOrEmpty(mini) ? null : mini;
|
return string.IsNullOrEmpty(mini) ? null : mini;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No texture in DB — borrow from a neighbour like Pascal does for loose dynamics.
|
|
||||||
if (index > 0)
|
if (index > 0)
|
||||||
{
|
{
|
||||||
var prev = _db.TextureForSkin(consist[index - 1].Cars[0].SkinFile);
|
var prev = _db.TextureForSkin(consist[index - 1].Cars[0].SkinFile);
|
||||||
@@ -208,20 +198,16 @@ public sealed class TexRandomizer
|
|||||||
result.Add(t);
|
result.Add(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always keep the current skin as a possible "no change" so Count < 2 means
|
|
||||||
// there is nothing else to pick from.
|
|
||||||
if (current != null && !result.Contains(current))
|
if (current != null && !result.Contains(current))
|
||||||
result.Add(current);
|
result.Add(current);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Pulls a 4-digit year from dates like <c>12.05.2018</c> or <c>2018-05-12</c>.</summary>
|
|
||||||
public static int? ParseRevisionYear(string? revision)
|
public static int? ParseRevisionYear(string? revision)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(revision)) return null;
|
if (string.IsNullOrWhiteSpace(revision)) return null;
|
||||||
|
|
||||||
// Pascal: Copy(Revision, 7, 4) on dd.mm.yyyy
|
|
||||||
if (revision.Length >= 10 && revision[2] == '.' && revision[5] == '.' &&
|
if (revision.Length >= 10 && revision[2] == '.' && revision[5] == '.' &&
|
||||||
int.TryParse(revision.AsSpan(6, 4), out int yDot) && yDot is >= 1900 and <= 2100)
|
int.TryParse(revision.AsSpan(6, 4), out int yDot) && yDot is >= 1900 and <= 2100)
|
||||||
return yDot;
|
return yDot;
|
||||||
|
|||||||
@@ -8,17 +8,12 @@ using StarterNG.Classes;
|
|||||||
|
|
||||||
namespace StarterNG.Domain;
|
namespace StarterNG.Domain;
|
||||||
|
|
||||||
/// <summary>Shared trainset list labels and status-bar formatting for Scenarios / Depot.</summary>
|
|
||||||
public static class TrainsetDisplay
|
public static class TrainsetDisplay
|
||||||
{
|
{
|
||||||
public static bool IsEmpty(Trainset trainset) =>
|
public static bool IsEmpty(Trainset trainset) =>
|
||||||
trainset.Vehicles.Count == 0 ||
|
trainset.Vehicles.Count == 0 ||
|
||||||
trainset.Vehicles.All(v => string.IsNullOrWhiteSpace(v.Name) && string.IsNullOrWhiteSpace(v.SkinFile));
|
trainset.Vehicles.All(v => string.IsNullOrWhiteSpace(v.Name) && string.IsNullOrWhiteSpace(v.SkinFile));
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Composition caption like Pascal <c>PrepareTrainsetDesc</c>: first loco/EZT by
|
|
||||||
/// name, then collapse consecutive same-class wagons as <c>Mini(N)</c>.
|
|
||||||
/// </summary>
|
|
||||||
public static string? CompositionText(Trainset trainset)
|
public static string? CompositionText(Trainset trainset)
|
||||||
{
|
{
|
||||||
if (IsEmpty(trainset))
|
if (IsEmpty(trainset))
|
||||||
@@ -78,10 +73,6 @@ public static class TrainsetDisplay
|
|||||||
string.IsNullOrWhiteSpace(v.SkinFile) ? "?" :
|
string.IsNullOrWhiteSpace(v.SkinFile) ? "?" :
|
||||||
System.IO.Path.GetFileNameWithoutExtension(v.SkinFile);
|
System.IO.Path.GetFileNameWithoutExtension(v.SkinFile);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Like Pascal <c>SelectTrain</c>: prefer the last car if it has cab crew
|
|
||||||
/// (push-pull), else the first staffed/passenger car, else the first car.
|
|
||||||
/// </summary>
|
|
||||||
public static string? DefaultStartingVehicle(Trainset trainset)
|
public static string? DefaultStartingVehicle(Trainset trainset)
|
||||||
{
|
{
|
||||||
if (trainset.Vehicles.Count == 0)
|
if (trainset.Vehicles.Count == 0)
|
||||||
@@ -96,10 +87,6 @@ public static class TrainsetDisplay
|
|||||||
return staffed?.Name ?? trainset.Vehicles[0].Name;
|
return staffed?.Name ?? trainset.Vehicles[0].Name;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Length / mass / vehicle count / track (+ speed), with Pascal mass rules
|
|
||||||
/// (<c>IncludeVehicleToMass</c> + 200 t AllVehicles retry).
|
|
||||||
/// </summary>
|
|
||||||
public static string FormatStats(Trainset? trainset, Func<Dynamic, Physics?> physicsFor,
|
public static string FormatStats(Trainset? trainset, Func<Dynamic, Physics?> physicsFor,
|
||||||
string lengthLabel, string massLabel, string vehiclesLabel, string trackLabel,
|
string lengthLabel, string massLabel, string vehiclesLabel, string trackLabel,
|
||||||
Func<Dynamic, string?>? categoryOf = null,
|
Func<Dynamic, string?>? categoryOf = null,
|
||||||
@@ -171,11 +158,6 @@ public static class TrainsetDisplay
|
|||||||
return p.LoadAccepted.Contains(loadType, StringComparison.OrdinalIgnoreCase);
|
return p.LoadAccepted.Contains(loadType, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pascal <c>UniqueVehicleName</c> / <c>UniqueVehiclesName</c> at launch:
|
|
||||||
/// EZT/szynobus → rename every car; otherwise only the start vehicle.
|
|
||||||
/// Returns the (possibly new) name of the start vehicle.
|
|
||||||
/// </summary>
|
|
||||||
public static string? UniquifyForLaunch(Trainset trainset, Scenery scenery, string? startName)
|
public static string? UniquifyForLaunch(Trainset trainset, Scenery scenery, string? startName)
|
||||||
{
|
{
|
||||||
var used = CollectNames(scenery);
|
var used = CollectNames(scenery);
|
||||||
@@ -220,7 +202,7 @@ public static class TrainsetDisplay
|
|||||||
var tex = GameData.Instance.Vehicles.TextureForSkin(v.SkinFile);
|
var tex = GameData.Instance.Vehicles.TextureForSkin(v.SkinFile);
|
||||||
if (tex != null)
|
if (tex != null)
|
||||||
{
|
{
|
||||||
// Prefer texture_mini when it is more specific than the class mini (≈ MiniD).
|
|
||||||
if (!string.IsNullOrEmpty(tex.TextureMini) &&
|
if (!string.IsNullOrEmpty(tex.TextureMini) &&
|
||||||
!string.Equals(tex.TextureMini, tex.ResolvedClass, StringComparison.OrdinalIgnoreCase))
|
!string.Equals(tex.TextureMini, tex.ResolvedClass, StringComparison.OrdinalIgnoreCase))
|
||||||
return SanitizeName(tex.TextureMini!);
|
return SanitizeName(tex.TextureMini!);
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ using Avalonia.Platform;
|
|||||||
|
|
||||||
namespace StarterNG.Infrastructure;
|
namespace StarterNG.Infrastructure;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pascal <c>PrepareLoadingScreen</c>: pick a logo and write <c>textures/logo.bmp</c>
|
|
||||||
/// for the simulator splash.
|
|
||||||
/// </summary>
|
|
||||||
public static class LoadingScreen
|
public static class LoadingScreen
|
||||||
{
|
{
|
||||||
public static void Prepare(string? logoKey, string? sceneryName = null)
|
public static void Prepare(string? logoKey, string? sceneryName = null)
|
||||||
@@ -35,7 +31,7 @@ public static class LoadingScreen
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// never block launch on a bad logo
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,23 +5,14 @@ using Avalonia.Media;
|
|||||||
|
|
||||||
namespace StarterNG.Infrastructure;
|
namespace StarterNG.Infrastructure;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A fixed-width font family that actually exists on this machine.
|
|
||||||
/// Avalonia treats the extra names in "Consolas, monospace" as glyph fallbacks,
|
|
||||||
/// not as family alternatives, and "monospace" is a fontconfig alias it never
|
|
||||||
/// resolves — so that stack silently renders in the proportional UI font
|
|
||||||
/// everywhere except Windows. The timetable and the fault/preview boxes align
|
|
||||||
/// their columns with spaces, so they need a real monospace family: probe the
|
|
||||||
/// installed ones once and keep the first match.
|
|
||||||
/// </summary>
|
|
||||||
public static class MonoFont
|
public static class MonoFont
|
||||||
{
|
{
|
||||||
private static readonly string[] Preferred =
|
private static readonly string[] Preferred =
|
||||||
{
|
{
|
||||||
"Consolas", // Windows
|
"Consolas",
|
||||||
"Menlo", // macOS
|
"Menlo",
|
||||||
"SF Mono",
|
"SF Mono",
|
||||||
"DejaVu Sans Mono", // most Linux distros
|
"DejaVu Sans Mono",
|
||||||
"Liberation Mono",
|
"Liberation Mono",
|
||||||
"Noto Sans Mono",
|
"Noto Sans Mono",
|
||||||
"Ubuntu Mono",
|
"Ubuntu Mono",
|
||||||
@@ -42,13 +33,12 @@ public static class MonoFont
|
|||||||
if (installed.Contains(name))
|
if (installed.Contains(name))
|
||||||
return new FontFamily(name);
|
return new FontFamily(name);
|
||||||
|
|
||||||
// None of the usual suspects: take any family that calls itself Mono.
|
|
||||||
string? anyMono = installed.FirstOrDefault(
|
string? anyMono = installed.FirstOrDefault(
|
||||||
n => n.Contains("Mono", StringComparison.OrdinalIgnoreCase));
|
n => n.Contains("Mono", StringComparison.OrdinalIgnoreCase));
|
||||||
if (anyMono is not null)
|
if (anyMono is not null)
|
||||||
return new FontFamily(anyMono);
|
return new FontFamily(anyMono);
|
||||||
}
|
}
|
||||||
catch { /* no font manager yet - fall back to the default family */ }
|
catch { }
|
||||||
|
|
||||||
return FontFamily.Default;
|
return FontFamily.Default;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ namespace StarterNG;
|
|||||||
|
|
||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
// Height of the caption strip the OS buttons live in.
|
|
||||||
private const int TitleBarHeight = 32;
|
private const int TitleBarHeight = 32;
|
||||||
|
|
||||||
private DateTime _openedUtc = DateTime.UtcNow;
|
private DateTime _openedUtc = DateTime.UtcNow;
|
||||||
@@ -30,24 +30,15 @@ public partial class MainWindow : Window
|
|||||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
// Extend the client area over the title bar so the logo + navigation sit
|
|
||||||
// on the top bar, while the OS caption buttons (min / max / close) stay in
|
|
||||||
// the top-right corner. The default chrome hints already keep the system
|
|
||||||
// caption buttons, so only the two supported hints are set here (this
|
|
||||||
// Avalonia build no longer exposes ExtendClientAreaChromeHints).
|
|
||||||
ExtendClientAreaToDecorationsHint = true;
|
ExtendClientAreaToDecorationsHint = true;
|
||||||
ExtendClientAreaTitleBarHeightHint = TitleBarHeight;
|
ExtendClientAreaTitleBarHeightHint = TitleBarHeight;
|
||||||
|
|
||||||
// Windows draws the min / max / close buttons over the top of the extended
|
|
||||||
// client area, on top of the toolbar row. Push the bar down by the caption
|
|
||||||
// height so they get a strip of their own.
|
|
||||||
if (OperatingSystem.IsWindows())
|
if (OperatingSystem.IsWindows())
|
||||||
TopBar.Padding = new Thickness(0, TitleBarHeight, 0, 0);
|
TopBar.Padding = new Thickness(0, TitleBarHeight, 0, 0);
|
||||||
|
|
||||||
PopulateLangCombo();
|
PopulateLangCombo();
|
||||||
App.Loc.LanguageChanged += OnLanguageChanged;
|
App.Loc.LanguageChanged += OnLanguageChanged;
|
||||||
|
|
||||||
// Keep Start enabled/caption in sync with selection (Pascal actStartUpdate).
|
|
||||||
AppState.Instance.Changed += UpdateStartButton;
|
AppState.Instance.Changed += UpdateStartButton;
|
||||||
Opened += (_, _) =>
|
Opened += (_, _) =>
|
||||||
{
|
{
|
||||||
@@ -57,20 +48,17 @@ public partial class MainWindow : Window
|
|||||||
Activated += (_, _) => CheckExternalSettings();
|
Activated += (_, _) => CheckExternalSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lets the user drag the window by the top caption strip.
|
|
||||||
private void TitleBar_OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
private void TitleBar_OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
BeginMoveDrag(e);
|
BeginMoveDrag(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Navigation: show the page whose RadioButton was just checked ──────────
|
|
||||||
private void Nav_OnCheckedChanged(object? sender, RoutedEventArgs e)
|
private void Nav_OnCheckedChanged(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is not RadioButton { IsChecked: true } rb)
|
if (sender is not RadioButton { IsChecked: true } rb)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Guard against the controls not being created yet during initial load.
|
|
||||||
if (ScenariosView is null || DepotView is null || SettingsView is null)
|
if (ScenariosView is null || DepotView is null || SettingsView is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -80,9 +68,6 @@ public partial class MainWindow : Window
|
|||||||
SettingsView.IsVisible = page == "settings";
|
SettingsView.IsVisible = page == "settings";
|
||||||
}
|
}
|
||||||
|
|
||||||
// One entry per file in lang/. The entries are language names, which are not
|
|
||||||
// translated, so this runs once - never from OnLanguageChanged, where it
|
|
||||||
// would clear the combo from inside its own SelectionChanged and throw.
|
|
||||||
private void PopulateLangCombo()
|
private void PopulateLangCombo()
|
||||||
{
|
{
|
||||||
LangCombo.Items.Clear();
|
LangCombo.Items.Clear();
|
||||||
@@ -94,8 +79,6 @@ public partial class MainWindow : Window
|
|||||||
SelectActiveLanguage();
|
SelectActiveLanguage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Points the combo at the loaded language; a no-op when it already shows it,
|
|
||||||
// which is the case for a pick made here.
|
|
||||||
private void SelectActiveLanguage()
|
private void SelectActiveLanguage()
|
||||||
{
|
{
|
||||||
var item = LangCombo.Items.OfType<ComboBoxItem>()
|
var item = LangCombo.Items.OfType<ComboBoxItem>()
|
||||||
@@ -112,18 +95,12 @@ public partial class MainWindow : Window
|
|||||||
if ((LangCombo.SelectedItem as ComboBoxItem)?.Tag is not string code)
|
if ((LangCombo.SelectedItem as ComboBoxItem)?.Tag is not string code)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Filling the combo in selects the language that is already loaded, which
|
|
||||||
// lands here as well - comparing against the loaded one is what tells the
|
|
||||||
// two apart, and it keeps the handler idempotent.
|
|
||||||
if (string.Equals(code, App.Loc.CurrentLangCode, StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(code, App.Loc.CurrentLangCode, StringComparison.OrdinalIgnoreCase))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
App.ApplyLanguage(code);
|
App.ApplyLanguage(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Captions built in code (train stats, panel headers, combo items) are read
|
|
||||||
// from App.Loc once, so they have to be built again. Runs for a pick made
|
|
||||||
// here or in the settings tab, hence hanging off Loc rather off the combo.
|
|
||||||
private void OnLanguageChanged()
|
private void OnLanguageChanged()
|
||||||
{
|
{
|
||||||
SelectActiveLanguage();
|
SelectActiveLanguage();
|
||||||
@@ -204,8 +181,6 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pascal actStartUpdate: Start needs a scenery and a trainset. A consist with
|
|
||||||
// nobody aboard still starts, after the confirmation in StartButton_OnClick.
|
|
||||||
private void UpdateStartButton()
|
private void UpdateStartButton()
|
||||||
{
|
{
|
||||||
if (startButton is null) return;
|
if (startButton is null) return;
|
||||||
@@ -225,8 +200,7 @@ public partial class MainWindow : Window
|
|||||||
startButton.IsEnabled = false;
|
startButton.IsEnabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// No driver and no passenger: still startable, but say so on the button
|
|
||||||
// and ask before launching.
|
|
||||||
bool staffed = HasStartableVehicle(trainset);
|
bool staffed = HasStartableVehicle(trainset);
|
||||||
startButton.Content = staffed ? App.Loc["Start"] : App.Loc["StartNoStaff"];
|
startButton.Content = staffed ? App.Loc["Start"] : App.Loc["StartNoStaff"];
|
||||||
ToolTip.SetTip(startButton, staffed ? null : App.Loc["StartNoStaffConfirm"]);
|
ToolTip.SetTip(startButton, staffed ? null : App.Loc["StartNoStaffConfirm"]);
|
||||||
@@ -249,8 +223,6 @@ public partial class MainWindow : Window
|
|||||||
return trainset.Vehicles.Any(CanStart);
|
return trainset.Vehicles.Any(CanStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── START: export the (possibly depot-modified) scenery and launch the sim.
|
|
||||||
// Global on the main window, like the original Starter's bottom START button.
|
|
||||||
private async void StartButton_OnClick(object? sender, RoutedEventArgs e)
|
private async void StartButton_OnClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var scenery = AppState.Instance.CurrentScenery;
|
var scenery = AppState.Instance.CurrentScenery;
|
||||||
@@ -260,8 +232,6 @@ public partial class MainWindow : Window
|
|||||||
if (trainset is null)
|
if (trainset is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Launching a consist with no driver and no passenger is legal (a scenery
|
|
||||||
// can be watched from the outside), so confirm instead of blocking it.
|
|
||||||
if (!HasStartableVehicle(trainset) &&
|
if (!HasStartableVehicle(trainset) &&
|
||||||
!await MessageBox.Show(this, App.Loc["StartNoStaffConfirm"],
|
!await MessageBox.Show(this, App.Loc["StartNoStaffConfirm"],
|
||||||
App.Loc["StartNoStaffTitle"], MessageBoxButtons.YesNo))
|
App.Loc["StartNoStaffTitle"], MessageBoxButtons.YesNo))
|
||||||
@@ -277,7 +247,6 @@ public partial class MainWindow : Window
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pascal UniqueVehicleName / UniqueVehiclesName before writing $scn.
|
|
||||||
string? vehicle = TrainsetDisplay.UniquifyForLaunch(
|
string? vehicle = TrainsetDisplay.UniquifyForLaunch(
|
||||||
trainset, scenery, AppState.Instance.StartingVehicleName);
|
trainset, scenery, AppState.Instance.StartingVehicleName);
|
||||||
if (string.IsNullOrEmpty(vehicle))
|
if (string.IsNullOrEmpty(vehicle))
|
||||||
@@ -286,11 +255,8 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path));
|
LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path));
|
||||||
|
|
||||||
// Always inject the Weather-tab values (incl. start-time override), like the
|
|
||||||
// Pascal starter's ChangeConfig on every launch — not only after an edit.
|
|
||||||
scenery.WeatherDirty = true;
|
scenery.WeatherDirty = true;
|
||||||
|
|
||||||
// write scenery/$<name>.scn with the replaced trainsets
|
|
||||||
string dir = Path.GetDirectoryName(scenery.Path) ?? "scenery";
|
string dir = Path.GetDirectoryName(scenery.Path) ?? "scenery";
|
||||||
string exportName = "$" + Path.GetFileName(scenery.Path);
|
string exportName = "$" + Path.GetFileName(scenery.Path);
|
||||||
string exportPath = Path.Combine(dir, exportName);
|
string exportPath = Path.Combine(dir, exportName);
|
||||||
@@ -300,16 +266,11 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return; // couldn't write the scenery file
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// make sure the latest settings are on disk before the sim reads them
|
|
||||||
SettingsModel.Instance.CaptureAndSave();
|
SettingsModel.Instance.CaptureAndSave();
|
||||||
|
|
||||||
// launch the game: -s $<name>.scn -v <vehicle>. Keep the handle so we can
|
|
||||||
// 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;
|
Process? sim;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -324,29 +285,23 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return; // executable not found / not configured yet
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// honour the "close starter automatically" preference
|
|
||||||
if (SettingsModel.Instance.AutoCloseStarter)
|
if (SettingsModel.Instance.AutoCloseStarter)
|
||||||
{
|
{
|
||||||
Close();
|
Close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise keep the starter running but hidden while the sim is in the
|
|
||||||
// foreground, and bring it back once the simulator process exits.
|
|
||||||
Hide();
|
Hide();
|
||||||
|
|
||||||
// reduce ram usage while the sim runs
|
|
||||||
GC.Collect();
|
GC.Collect();
|
||||||
GC.WaitForPendingFinalizers();
|
GC.WaitForPendingFinalizers();
|
||||||
|
|
||||||
WatchSimulator(sim);
|
WatchSimulator(sim);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pascal: RunInfo.Vehicle := Train.Vehicles[SelVehicle].Name after actStartUpdate
|
|
||||||
// requires headdriver/reardriver/passenger on that car.
|
|
||||||
private static string? ResolveStartVehicle(Trainset? trainset)
|
private static string? ResolveStartVehicle(Trainset? trainset)
|
||||||
{
|
{
|
||||||
if (trainset is null || trainset.Vehicles.Count == 0)
|
if (trainset is null || trainset.Vehicles.Count == 0)
|
||||||
@@ -368,9 +323,6 @@ public partial class MainWindow : Window
|
|||||||
?? trainset.Vehicles.FirstOrDefault()?.Name;
|
?? trainset.Vehicles.FirstOrDefault()?.Name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Waits (off the UI thread) for the simulator to exit, then restores the
|
|
||||||
// starter window. Uses the launch handle when available, otherwise locates the
|
|
||||||
// process by name, and never leaves the UI hidden if it cannot be tracked.
|
|
||||||
private void WatchSimulator(Process? sim)
|
private void WatchSimulator(Process? sim)
|
||||||
{
|
{
|
||||||
if (sim is null)
|
if (sim is null)
|
||||||
@@ -381,7 +333,7 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
if (sim is null)
|
if (sim is null)
|
||||||
{
|
{
|
||||||
// no handle to wait on - restore immediately rather than hide forever
|
|
||||||
Dispatcher.UIThread.Post(RestoreFromSimulator);
|
Dispatcher.UIThread.Post(RestoreFromSimulator);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -389,7 +341,7 @@ public partial class MainWindow : Window
|
|||||||
var watcher = new Thread(() =>
|
var watcher = new Thread(() =>
|
||||||
{
|
{
|
||||||
try { sim.WaitForExit(); }
|
try { sim.WaitForExit(); }
|
||||||
catch { /* already exited / access denied */ }
|
catch { }
|
||||||
Dispatcher.UIThread.Post(RestoreFromSimulator);
|
Dispatcher.UIThread.Post(RestoreFromSimulator);
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
@@ -399,7 +351,6 @@ public partial class MainWindow : Window
|
|||||||
watcher.Start();
|
watcher.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pascal CheckSettingsFile on AppActivate (skip the first few seconds).
|
|
||||||
private async void CheckExternalSettings()
|
private async void CheckExternalSettings()
|
||||||
{
|
{
|
||||||
if ((DateTime.UtcNow - _openedUtc).TotalSeconds < 3)
|
if ((DateTime.UtcNow - _openedUtc).TotalSeconds < 3)
|
||||||
@@ -421,7 +372,6 @@ public partial class MainWindow : Window
|
|||||||
SettingsModel.Instance.TouchConfigAge();
|
SettingsModel.Instance.TouchConfigAge();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Brings the starter back to the foreground after the simulator closes.
|
|
||||||
private void RestoreFromSimulator()
|
private void RestoreFromSimulator()
|
||||||
{
|
{
|
||||||
Show();
|
Show();
|
||||||
|
|||||||
@@ -5,17 +5,14 @@ namespace StarterNG;
|
|||||||
|
|
||||||
class Program
|
class Program
|
||||||
{
|
{
|
||||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
|
||||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
|
||||||
// yet and stuff might break.
|
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||||
.StartWithClassicDesktopLifetime(args);
|
.StartWithClassicDesktopLifetime(args);
|
||||||
|
|
||||||
// Avalonia configuration, don't remove; also used by visual designer.
|
|
||||||
public static AppBuilder BuildAvaloniaApp()
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
=> AppBuilder.Configure<App>()
|
=> AppBuilder.Configure<App>()
|
||||||
.UsePlatformDetect()
|
.UsePlatformDetect()
|
||||||
.WithInterFont()
|
.WithInterFont()
|
||||||
.LogToTrace();
|
.LogToTrace();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,8 @@ using System.Xml;
|
|||||||
|
|
||||||
namespace StarterNG.Services;
|
namespace StarterNG.Services;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Loads UI translations from plain XML files shipped in a <c>lang/</c> folder next
|
|
||||||
/// to the executable (instead of dictionaries compiled into the assembly). The files
|
|
||||||
/// are parsed with <see cref="XmlReader"/>, which works under Native AOT — unlike the
|
|
||||||
/// Avalonia XAML loader, which has no runtime parser in AOT builds.
|
|
||||||
///
|
|
||||||
/// File format (lang/en.xml):
|
|
||||||
/// <code>
|
|
||||||
/// <Language code="en" name="English">
|
|
||||||
/// <String key="NavScenarios">Scenarios</String>
|
|
||||||
/// </Language>
|
|
||||||
/// </code>
|
|
||||||
/// </summary>
|
|
||||||
public class LocalizationService : INotifyPropertyChanged
|
public class LocalizationService : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
/// <summary>Folder holding the language files, resolved as starter/lang next to the executable.</summary>
|
|
||||||
public static string LangDirectory =>
|
public static string LangDirectory =>
|
||||||
Path.Combine(AppContext.BaseDirectory, "startercfg", "lang");
|
Path.Combine(AppContext.BaseDirectory, "startercfg", "lang");
|
||||||
|
|
||||||
@@ -29,32 +15,17 @@ public class LocalizationService : INotifyPropertyChanged
|
|||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Raised after a language file is loaded. XAML bindings refresh themselves
|
|
||||||
/// through <see cref="PropertyChanged"/>; this is for captions that code
|
|
||||||
/// builds once and has to build again.
|
|
||||||
/// </summary>
|
|
||||||
public event Action? LanguageChanged;
|
public event Action? LanguageChanged;
|
||||||
|
|
||||||
/// <summary>Display name of the active language (e.g. "English", "Polski").</summary>
|
|
||||||
public string CurrentLanguage { get; private set; } = "English";
|
public string CurrentLanguage { get; private set; } = "English";
|
||||||
|
|
||||||
/// <summary>Short ISO code of the active language (e.g. "en", "pl").</summary>
|
|
||||||
public string CurrentLangCode { get; private set; } = "en";
|
public string CurrentLangCode { get; private set; } = "en";
|
||||||
|
|
||||||
/// <summary>Indexer used by the XAML bindings: returns the key itself when missing.</summary>
|
|
||||||
public string this[string key] =>
|
public string this[string key] =>
|
||||||
_strings.TryGetValue(key, out var value) ? value : key;
|
_strings.TryGetValue(key, out var value) ? value : key;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Metadata for one discovered language file.
|
|
||||||
/// </summary>
|
|
||||||
public readonly record struct LanguageInfo(string Code, string Name, string Path);
|
public readonly record struct LanguageInfo(string Code, string Name, string Path);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Scans the lang/ folder and returns the header metadata of every *.xml file
|
|
||||||
/// without loading all of its strings. Used to populate the language selector.
|
|
||||||
/// </summary>
|
|
||||||
public static IReadOnlyList<LanguageInfo> AvailableLanguages()
|
public static IReadOnlyList<LanguageInfo> AvailableLanguages()
|
||||||
{
|
{
|
||||||
var list = new List<LanguageInfo>();
|
var list = new List<LanguageInfo>();
|
||||||
@@ -71,17 +42,12 @@ public class LocalizationService : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Skip malformed files rather than crashing the launcher.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Loads the language whose code or display name matches <paramref name="codeOrName"/>.
|
|
||||||
/// Falls back to English ("en") when no match is found. Returns the resolved
|
|
||||||
/// display name so callers can keep their stored setting consistent.
|
|
||||||
/// </summary>
|
|
||||||
public string Load(string codeOrName)
|
public string Load(string codeOrName)
|
||||||
{
|
{
|
||||||
var languages = AvailableLanguages();
|
var languages = AvailableLanguages();
|
||||||
@@ -97,7 +63,6 @@ public class LocalizationService : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to English, then to whatever is available first.
|
|
||||||
if (match is null)
|
if (match is null)
|
||||||
{
|
{
|
||||||
foreach (var lang in languages)
|
foreach (var lang in languages)
|
||||||
@@ -114,14 +79,12 @@ public class LocalizationService : INotifyPropertyChanged
|
|||||||
LoadFile(info.Path, info.Code, info.Name);
|
LoadFile(info.Path, info.Code, info.Name);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// No files at all: keep keys visible instead of blank UI.
|
|
||||||
_strings = new Dictionary<string, string>();
|
_strings = new Dictionary<string, string>();
|
||||||
CurrentLangCode = "en";
|
CurrentLangCode = "en";
|
||||||
CurrentLanguage = "English";
|
CurrentLanguage = "English";
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Item[]" is the indexer name bindings listen for; the empty name asks
|
|
||||||
// every binding on this object to re-read.
|
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]"));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]"));
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty));
|
||||||
LanguageChanged?.Invoke();
|
LanguageChanged?.Invoke();
|
||||||
@@ -135,16 +98,12 @@ public class LocalizationService : INotifyPropertyChanged
|
|||||||
var settings = new XmlReaderSettings { IgnoreComments = true, IgnoreWhitespace = true };
|
var settings = new XmlReaderSettings { IgnoreComments = true, IgnoreWhitespace = true };
|
||||||
using var reader = XmlReader.Create(path, settings);
|
using var reader = XmlReader.Create(path, settings);
|
||||||
|
|
||||||
// NOTE: ReadElementContentAsString() already advances the reader past the
|
|
||||||
// element's end tag onto the *next* node, so we must NOT call Read() again
|
|
||||||
// in that case - doing so would skip every second <String> entry. We only
|
|
||||||
// advance explicitly for nodes we don't consume here.
|
|
||||||
while (!reader.EOF)
|
while (!reader.EOF)
|
||||||
{
|
{
|
||||||
if (reader.NodeType == XmlNodeType.Element && reader.Name == "String")
|
if (reader.NodeType == XmlNodeType.Element && reader.Name == "String")
|
||||||
{
|
{
|
||||||
var key = reader.GetAttribute("key");
|
var key = reader.GetAttribute("key");
|
||||||
var value = reader.ReadElementContentAsString(); // advances the reader
|
var value = reader.ReadElementContentAsString();
|
||||||
if (!string.IsNullOrEmpty(key))
|
if (!string.IsNullOrEmpty(key))
|
||||||
dict[key] = value;
|
dict[key] = value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ public partial class SplashWindow : Window
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Updates the bar and status line. Must be called on the UI thread.</summary>
|
|
||||||
public void Report(LoadStatus status)
|
public void Report(LoadStatus status)
|
||||||
{
|
{
|
||||||
progressBar.Value = status.Fraction;
|
progressBar.Value = status.Fraction;
|
||||||
|
|||||||
@@ -11,18 +11,9 @@ using StarterNG.Classes;
|
|||||||
|
|
||||||
namespace StarterNG.Views;
|
namespace StarterNG.Views;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Right-click menu shared by the consist lists (the "Vehicles" list in Scenarios
|
|
||||||
/// and the "Map consists" list in the depot). Offers: save to warehouse, load from
|
|
||||||
/// warehouse (each entry deletable via an "✕"), copy/paste via the clipboard, and -
|
|
||||||
/// when <c>removeAll</c> is supplied - clearing the consist (Shift+Delete).
|
|
||||||
/// </summary>
|
|
||||||
public static class ConsistContextMenu
|
public static class ConsistContextMenu
|
||||||
{
|
{
|
||||||
/// <param name="owner">Control the menu/flyouts anchor to (for clipboard + popups).</param>
|
|
||||||
/// <param name="getTarget">Supplies the trainset to act on when the menu opens.</param>
|
|
||||||
/// <param name="applyVehicles">Replaces the target's vehicles and refreshes the view.</param>
|
|
||||||
/// <param name="removeAll">When non-null, adds a "remove all vehicles" entry.</param>
|
|
||||||
public static ContextMenu Build(
|
public static ContextMenu Build(
|
||||||
Control owner,
|
Control owner,
|
||||||
Func<Trainset?> getTarget,
|
Func<Trainset?> getTarget,
|
||||||
@@ -30,7 +21,7 @@ public static class ConsistContextMenu
|
|||||||
Action? removeAll = null)
|
Action? removeAll = null)
|
||||||
{
|
{
|
||||||
var menu = new ContextMenu();
|
var menu = new ContextMenu();
|
||||||
// Rebuild the entries each time it opens so the warehouse list stays fresh.
|
|
||||||
menu.Opening += (_, _) => Populate(menu, owner, getTarget, applyVehicles, removeAll);
|
menu.Opening += (_, _) => Populate(menu, owner, getTarget, applyVehicles, removeAll);
|
||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
@@ -42,12 +33,10 @@ public static class ConsistContextMenu
|
|||||||
var target = getTarget();
|
var target = getTarget();
|
||||||
bool hasVehicles = target is { Vehicles.Count: > 0 };
|
bool hasVehicles = target is { Vehicles.Count: > 0 };
|
||||||
|
|
||||||
// ── Save current consist to the warehouse ──────────────────────────────
|
|
||||||
var save = new MenuItem { Header = App.Loc["PresetSave"], IsEnabled = hasVehicles };
|
var save = new MenuItem { Header = App.Loc["PresetSave"], IsEnabled = hasVehicles };
|
||||||
save.Click += (_, _) => ShowSaveFlyout(owner, target);
|
save.Click += (_, _) => ShowSaveFlyout(owner, target);
|
||||||
menu.Items.Add(save);
|
menu.Items.Add(save);
|
||||||
|
|
||||||
// ── Load from the warehouse (submenu; each entry deletable) ────────────
|
|
||||||
var load = new MenuItem { Header = App.Loc["PresetLoad"] };
|
var load = new MenuItem { Header = App.Loc["PresetLoad"] };
|
||||||
var presets = PresetStore.All();
|
var presets = PresetStore.All();
|
||||||
if (presets.Count == 0)
|
if (presets.Count == 0)
|
||||||
@@ -82,7 +71,6 @@ public static class ConsistContextMenu
|
|||||||
|
|
||||||
menu.Items.Add(new Separator());
|
menu.Items.Add(new Separator());
|
||||||
|
|
||||||
// ── Clipboard (full trainset text) ─────────────────────────────────────
|
|
||||||
var copy = new MenuItem { Header = App.Loc["ConsistCopy"], IsEnabled = hasVehicles };
|
var copy = new MenuItem { Header = App.Loc["ConsistCopy"], IsEnabled = hasVehicles };
|
||||||
copy.Click += async (_, _) =>
|
copy.Click += async (_, _) =>
|
||||||
{
|
{
|
||||||
@@ -103,7 +91,6 @@ public static class ConsistContextMenu
|
|||||||
};
|
};
|
||||||
menu.Items.Add(paste);
|
menu.Items.Add(paste);
|
||||||
|
|
||||||
// ── Remove all vehicles (depot only) ───────────────────────────────────
|
|
||||||
if (removeAll != null)
|
if (removeAll != null)
|
||||||
{
|
{
|
||||||
menu.Items.Add(new Separator());
|
menu.Items.Add(new Separator());
|
||||||
@@ -118,8 +105,6 @@ public static class ConsistContextMenu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A warehouse entry: the name loads the preset on click, the trailing "✕"
|
|
||||||
// deletes it from the warehouse.
|
|
||||||
private static MenuItem BuildPresetItem(ContextMenu menu, TrainsetPreset preset,
|
private static MenuItem BuildPresetItem(ContextMenu menu, TrainsetPreset preset,
|
||||||
Action<List<Dynamic>> applyVehicles)
|
Action<List<Dynamic>> applyVehicles)
|
||||||
{
|
{
|
||||||
@@ -134,9 +119,9 @@ public static class ConsistContextMenu
|
|||||||
ToolTip.SetTip(del, App.Loc["PresetDelete"]);
|
ToolTip.SetTip(del, App.Loc["PresetDelete"]);
|
||||||
del.Click += (_, e) =>
|
del.Click += (_, e) =>
|
||||||
{
|
{
|
||||||
e.Handled = true; // don't let the click also load the preset
|
e.Handled = true;
|
||||||
PresetStore.Delete(preset.Name);
|
PresetStore.Delete(preset.Name);
|
||||||
menu.Close(); // reopen to see the updated warehouse
|
menu.Close();
|
||||||
};
|
};
|
||||||
|
|
||||||
var label = new TextBlock { Text = preset.Name, VerticalAlignment = VerticalAlignment.Center };
|
var label = new TextBlock { Text = preset.Name, VerticalAlignment = VerticalAlignment.Center };
|
||||||
@@ -156,7 +141,6 @@ public static class ConsistContextMenu
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small popup to name the preset before saving it to the warehouse.
|
|
||||||
private static void ShowSaveFlyout(Control owner, Trainset? target)
|
private static void ShowSaveFlyout(Control owner, Trainset? target)
|
||||||
{
|
{
|
||||||
if (target is null || target.Vehicles.Count == 0)
|
if (target is null || target.Vehicles.Count == 0)
|
||||||
|
|||||||
@@ -21,25 +21,19 @@ namespace StarterNG.Views;
|
|||||||
|
|
||||||
public partial class Depot : UserControl
|
public partial class Depot : UserControl
|
||||||
{
|
{
|
||||||
// Data is parsed once at startup (behind the splash) and shared here.
|
|
||||||
private readonly VehicleDatabase _db = GameData.Instance.Vehicles;
|
private readonly VehicleDatabase _db = GameData.Instance.Vehicles;
|
||||||
private readonly List<Scenery> _sceneries = GameData.Instance.Sceneries;
|
private readonly List<Scenery> _sceneries = GameData.Instance.Sceneries;
|
||||||
|
|
||||||
// The consist being edited: a list of units (each unit = one or more cars).
|
|
||||||
private readonly List<ConsistItem> _consist = new();
|
private readonly List<ConsistItem> _consist = new();
|
||||||
private ConsistItem? _selected;
|
private ConsistItem? _selected;
|
||||||
|
|
||||||
// The scenery trainset this consist is bound to; edits are written back to it
|
|
||||||
// so the scenery is modified. Null when building an unattached consist.
|
|
||||||
private Trainset? _editingTrainset;
|
private Trainset? _editingTrainset;
|
||||||
|
|
||||||
// Shared, size-limited thumbnail cache (decoded down to display height).
|
|
||||||
private readonly Dictionary<string, Bitmap?> _miniCache = new();
|
private readonly Dictionary<string, Bitmap?> _miniCache = new();
|
||||||
private int ConsistThumbHeight =>
|
private int ConsistThumbHeight =>
|
||||||
StarterNG.Classes.Settings.Instance.LargeThumbnails ? 68 : 34;
|
StarterNG.Classes.Settings.Instance.LargeThumbnails ? 68 : 34;
|
||||||
|
|
||||||
// Display height of the mini under the vehicle list; the class combo uses
|
|
||||||
// the same size.
|
|
||||||
private const int MiniPreviewHeight = 54;
|
private const int MiniPreviewHeight = 54;
|
||||||
|
|
||||||
private const int CompactThumbHeight = 24;
|
private const int CompactThumbHeight = 24;
|
||||||
@@ -47,35 +41,25 @@ public partial class Depot : UserControl
|
|||||||
private readonly Cursor _hand = new(StandardCursorType.Hand);
|
private readonly Cursor _hand = new(StandardCursorType.Hand);
|
||||||
private int _nameCounter;
|
private int _nameCounter;
|
||||||
|
|
||||||
// shared RNG for the "random load / random amount" consist tools
|
|
||||||
private static readonly Random _rng = new();
|
private static readonly Random _rng = new();
|
||||||
|
|
||||||
// suppresses combo SelectionChanged handlers while we populate them
|
|
||||||
private bool _suppress;
|
private bool _suppress;
|
||||||
private bool _syncingCombos; // set while the combos mirror a pick
|
private bool _syncingCombos;
|
||||||
|
|
||||||
// debounces search input so we don't rebuild on every keystroke
|
|
||||||
private DispatcherTimer? _searchTimer;
|
private DispatcherTimer? _searchTimer;
|
||||||
|
|
||||||
// the scenery whose consists are listed on the right (from the Scenarios tab)
|
|
||||||
private Scenery? _listScenery;
|
private Scenery? _listScenery;
|
||||||
|
|
||||||
// active database filters
|
private Func<string?, bool>? _categoryFilter;
|
||||||
private Func<string?, bool>? _categoryFilter; // matches a category letter, null = none chosen
|
private string? _classFilter;
|
||||||
private string? _classFilter; // group mini (e.g. EU07), null = all in category
|
|
||||||
|
|
||||||
// the vehicle currently highlighted in the flat list (drives the mini preview
|
|
||||||
// and the single Add button)
|
|
||||||
private VehicleTexture? _browserSelected;
|
private VehicleTexture? _browserSelected;
|
||||||
|
|
||||||
// drag-drop payload while a pointer drag is in progress
|
|
||||||
private VehicleTexture? _dragTexture;
|
private VehicleTexture? _dragTexture;
|
||||||
private int _dragConsistIndex = -1;
|
private int _dragConsistIndex = -1;
|
||||||
private const string DragVehicleFormat = "starterng/vehicle";
|
private const string DragVehicleFormat = "starterng/vehicle";
|
||||||
private const string DragConsistFormat = "starterng/consist-index";
|
private const string DragConsistFormat = "starterng/consist-index";
|
||||||
|
|
||||||
// Armed on press, started only after the pointer moves past DragThreshold.
|
|
||||||
// Starting DoDragDropAsync on every click freezes Avalonia (pointer capture).
|
|
||||||
private PointerPressedEventArgs? _pendingDragPress;
|
private PointerPressedEventArgs? _pendingDragPress;
|
||||||
private Point _pendingDragOrigin;
|
private Point _pendingDragOrigin;
|
||||||
private VehicleTexture? _pendingDragTexture;
|
private VehicleTexture? _pendingDragTexture;
|
||||||
@@ -84,17 +68,13 @@ public partial class Depot : UserControl
|
|||||||
private bool _dragSessionActive;
|
private bool _dragSessionActive;
|
||||||
private const double DragThreshold = 6;
|
private const double DragThreshold = 6;
|
||||||
|
|
||||||
// hard cap on how many entries the flat list shows at once
|
|
||||||
private const int MaxListRows = 2000;
|
private const int MaxListRows = 2000;
|
||||||
|
|
||||||
// card highlight brushes (theme-neutral overlays)
|
|
||||||
private static readonly IBrush CardBorder = new SolidColorBrush(Color.Parse("#33888888"));
|
private static readonly IBrush CardBorder = new SolidColorBrush(Color.Parse("#33888888"));
|
||||||
private static readonly IBrush CardBorderSel = new SolidColorBrush(Color.Parse("#AA41C400"));
|
private static readonly IBrush CardBorderSel = new SolidColorBrush(Color.Parse("#AA41C400"));
|
||||||
private static readonly IBrush CardBgSel = new SolidColorBrush(Color.Parse("#2241C400"));
|
private static readonly IBrush CardBgSel = new SolidColorBrush(Color.Parse("#2241C400"));
|
||||||
private static readonly IBrush Placeholder = new SolidColorBrush(Color.Parse("#22808080"));
|
private static readonly IBrush Placeholder = new SolidColorBrush(Color.Parse("#22808080"));
|
||||||
|
|
||||||
// Vehicle types, keyed by the JSON "category" letter. Powered/technical
|
|
||||||
// vehicles use lowercase letters; wagons use uppercase letters.
|
|
||||||
private static readonly (string LocKey, Func<string?, bool> Match)[] CategoryDefs =
|
private static readonly (string LocKey, Func<string?, bool> Match)[] CategoryDefs =
|
||||||
{
|
{
|
||||||
("CatElectricLoco", c => c == "e"),
|
("CatElectricLoco", c => c == "e"),
|
||||||
@@ -136,22 +116,18 @@ public partial class Depot : UserControl
|
|||||||
("CatWagonsX", c => c == "X"),
|
("CatWagonsX", c => c == "X"),
|
||||||
("CatWagonsY", c => c == "Y"),
|
("CatWagonsY", c => c == "Y"),
|
||||||
("CatWagonsZ", c => c == "Z"),
|
("CatWagonsZ", c => c == "Z"),
|
||||||
|
|
||||||
("CatOther", IsOtherCat),
|
("CatOther", IsOtherCat),
|
||||||
};
|
};
|
||||||
|
|
||||||
// lowercase letters that already have a named type (so they aren't "other")
|
|
||||||
private static readonly HashSet<string> NamedLowerCats =
|
private static readonly HashSet<string> NamedLowerCats =
|
||||||
new(StringComparer.Ordinal) { "a", "b", "c", "d", "e", "f", "h", "n", "o", "p", "r", "s", "t", "x", "z" };
|
new(StringComparer.Ordinal) { "a", "b", "c", "d", "e", "f", "h", "n", "o", "p", "r", "s", "t", "x", "z" };
|
||||||
|
|
||||||
private static bool IsOtherCat(string? c) =>
|
private static bool IsOtherCat(string? c) =>
|
||||||
c is { Length: 1 } && char.IsLower(c[0]) && !NamedLowerCats.Contains(c);
|
c is { Length: 1 } && char.IsLower(c[0]) && !NamedLowerCats.Contains(c);
|
||||||
|
|
||||||
// Car-type suffixes (longest first) used only for the consist unit label.
|
|
||||||
private static readonly string[] CarSuffixes = { "sa", "sb", "ra", "rb", "s" };
|
private static readonly string[] CarSuffixes = { "sa", "sb", "ra", "rb", "s" };
|
||||||
|
|
||||||
// Coupling-bit localisation keys in mask-bit order (bit i = value 1<<i), per
|
|
||||||
// the wiki: https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow
|
|
||||||
private static readonly string[] CouplingBitKeys =
|
private static readonly string[] CouplingBitKeys =
|
||||||
{ "CplMechanical", "CplBrake", "CplControl", "CplHighVoltage",
|
{ "CplMechanical", "CplBrake", "CplControl", "CplHighVoltage",
|
||||||
"CplGangway", "CplAuxPneumatic", "CplHeating", "CplWorkshop" };
|
"CplGangway", "CplAuxPneumatic", "CplHeating", "CplWorkshop" };
|
||||||
@@ -170,17 +146,15 @@ public partial class Depot : UserControl
|
|||||||
BuildList();
|
BuildList();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Defer population so the tab renders immediately, then fills in.
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
hideArchivalCheck.IsChecked = StarterNG.Classes.Settings.Instance.HideArchivalVehicles;
|
hideArchivalCheck.IsChecked = StarterNG.Classes.Settings.Instance.HideArchivalVehicles;
|
||||||
InitClassComboTemplates();
|
InitClassComboTemplates();
|
||||||
PopulateCategoryCombo(); // -> populates class combo -> builds browser
|
PopulateCategoryCombo();
|
||||||
PopulateSceneryConsists(); // -> lists the current scenery's consists
|
PopulateSceneryConsists();
|
||||||
RebuildConsist();
|
RebuildConsist();
|
||||||
}, DispatcherPriority.Background);
|
}, DispatcherPriority.Background);
|
||||||
|
|
||||||
// Consist strip accepts drops from the browser and reorders via card drag.
|
|
||||||
DragDrop.SetAllowDrop(consistDropTarget, true);
|
DragDrop.SetAllowDrop(consistDropTarget, true);
|
||||||
consistDropTarget.AddHandler(DragDrop.DragOverEvent, Consist_OnDragOver);
|
consistDropTarget.AddHandler(DragDrop.DragOverEvent, Consist_OnDragOver);
|
||||||
consistDropTarget.AddHandler(DragDrop.DropEvent, Consist_OnDrop);
|
consistDropTarget.AddHandler(DragDrop.DropEvent, Consist_OnDrop);
|
||||||
@@ -190,9 +164,6 @@ public partial class Depot : UserControl
|
|||||||
miniPreviewPanel.AddHandler(InputElement.PointerMovedEvent, PendingDrag_OnPointerMoved, handledEventsToo: true);
|
miniPreviewPanel.AddHandler(InputElement.PointerMovedEvent, PendingDrag_OnPointerMoved, handledEventsToo: true);
|
||||||
miniPreviewPanel.AddHandler(InputElement.PointerReleasedEvent, PendingDrag_OnPointerReleased, handledEventsToo: true);
|
miniPreviewPanel.AddHandler(InputElement.PointerReleasedEvent, PendingDrag_OnPointerReleased, handledEventsToo: true);
|
||||||
|
|
||||||
// Switching tabs only toggles IsVisible (the view stays in the visual
|
|
||||||
// tree), so refresh whenever the depot becomes visible - this picks up a
|
|
||||||
// scenery changed in the Scenarios tab in the meantime.
|
|
||||||
AttachedToVisualTree += (_, _) => SyncFromSelection();
|
AttachedToVisualTree += (_, _) => SyncFromSelection();
|
||||||
PropertyChanged += (_, e) =>
|
PropertyChanged += (_, e) =>
|
||||||
{
|
{
|
||||||
@@ -203,7 +174,7 @@ public partial class Depot : UserControl
|
|||||||
|
|
||||||
private void SyncFromSelection()
|
private void SyncFromSelection()
|
||||||
{
|
{
|
||||||
// pick up Settings-tab changes to the archival-vehicle filter
|
|
||||||
bool hideArchival = StarterNG.Classes.Settings.Instance.HideArchivalVehicles;
|
bool hideArchival = StarterNG.Classes.Settings.Instance.HideArchivalVehicles;
|
||||||
if (hideArchivalCheck.IsChecked != hideArchival)
|
if (hideArchivalCheck.IsChecked != hideArchival)
|
||||||
{
|
{
|
||||||
@@ -213,19 +184,15 @@ public partial class Depot : UserControl
|
|||||||
BuildList();
|
BuildList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// refresh the on-map consists list to the scenery picked in the Scenarios tab
|
|
||||||
PopulateSceneryConsists();
|
PopulateSceneryConsists();
|
||||||
|
|
||||||
var trainset = AppState.Instance.CurrentTrainset;
|
var trainset = AppState.Instance.CurrentTrainset;
|
||||||
if (trainset != null && !ReferenceEquals(trainset, _editingTrainset))
|
if (trainset != null && !ReferenceEquals(trainset, _editingTrainset))
|
||||||
LoadTrainset(trainset);
|
LoadTrainset(trainset);
|
||||||
else if (_editingTrainset != null)
|
else if (_editingTrainset != null)
|
||||||
RebuildConsist(); // pick up LargeThumbnails / filter-driven list changes
|
RebuildConsist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loads a scenery trainset into the editor and binds edits back to it.
|
|
||||||
// Consecutive cars that belong to the same auto-consist set are regrouped
|
|
||||||
// into one locked unit so a scenery EN57 shows as a single vehicle.
|
|
||||||
private void LoadTrainset(Trainset trainset)
|
private void LoadTrainset(Trainset trainset)
|
||||||
{
|
{
|
||||||
_editingTrainset = trainset;
|
_editingTrainset = trainset;
|
||||||
@@ -234,7 +201,7 @@ public partial class Depot : UserControl
|
|||||||
var cars = trainset.Vehicles.Select(v =>
|
var cars = trainset.Vehicles.Select(v =>
|
||||||
{
|
{
|
||||||
var c = v.Clone();
|
var c = v.Clone();
|
||||||
c.MiniName = _db.MiniForSkin(c.SkinFile) ?? c.MiniName; // mini from texture_mini
|
c.MiniName = _db.MiniForSkin(c.SkinFile) ?? c.MiniName;
|
||||||
return c;
|
return c;
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
@@ -267,8 +234,7 @@ public partial class Depot : UserControl
|
|||||||
{
|
{
|
||||||
Cars = group,
|
Cars = group,
|
||||||
Grouped = true,
|
Grouped = true,
|
||||||
// FirstOrDefault(predicate) defaults the enum to Headdriver(0) when
|
|
||||||
// every car is Nobody — that re-staffed decorative consists (#12).
|
|
||||||
Driver = UnitDriver(group)
|
Driver = UnitDriver(group)
|
||||||
});
|
});
|
||||||
i = j;
|
i = j;
|
||||||
@@ -276,7 +242,6 @@ public partial class Depot : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback: group consecutive cars sharing a unit number (e.g. EN57-001ra/s/rb)
|
|
||||||
if (IsUnitCar(cars[i]))
|
if (IsUnitCar(cars[i]))
|
||||||
{
|
{
|
||||||
string key = UnitKey(cars[i]);
|
string key = UnitKey(cars[i]);
|
||||||
@@ -312,7 +277,6 @@ public partial class Depot : UserControl
|
|||||||
RebuildConsist();
|
RebuildConsist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// First staffed occupancy on the unit, or Nobody when the whole unit is unstaffed.
|
|
||||||
private static eDriverType UnitDriver(IReadOnlyList<Dynamic> group) =>
|
private static eDriverType UnitDriver(IReadOnlyList<Dynamic> group) =>
|
||||||
group.Select(c => c.DriverType)
|
group.Select(c => c.DriverType)
|
||||||
.Where(d => d != eDriverType.Nobody)
|
.Where(d => d != eDriverType.Nobody)
|
||||||
@@ -325,10 +289,6 @@ public partial class Depot : UserControl
|
|||||||
AppState.Instance.StartingVehicleName = _selected.Cars[0].Name;
|
AppState.Instance.StartingVehicleName = _selected.Cars[0].Name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------- series / unit helpers
|
|
||||||
|
|
||||||
// Removes a trailing car-type suffix that directly follows a digit
|
|
||||||
// ("EN57-001ra" -> "EN57-001", "EN57ra" -> "EN57", "EP07-332" -> unchanged).
|
|
||||||
private static string StripCarSuffix(string name)
|
private static string StripCarSuffix(string name)
|
||||||
{
|
{
|
||||||
foreach (string suf in CarSuffixes)
|
foreach (string suf in CarSuffixes)
|
||||||
@@ -345,23 +305,15 @@ public partial class Depot : UserControl
|
|||||||
|
|
||||||
private static string UnitKey(Dynamic d) => StripCarSuffix(Base(d.SkinFile));
|
private static string UnitKey(Dynamic d) => StripCarSuffix(Base(d.SkinFile));
|
||||||
|
|
||||||
// True when the skin has a car-type suffix (so it is part of a multi-car unit).
|
|
||||||
private static bool IsUnitCar(Dynamic d) => UnitKey(d) != Base(d.SkinFile);
|
private static bool IsUnitCar(Dynamic d) => UnitKey(d) != Base(d.SkinFile);
|
||||||
|
|
||||||
// Vehicle class = the "mini" of the group the texture belongs to (e.g. EU07).
|
|
||||||
// Resolved once at load (see VehicleDatabase.BuildTextureIndex); read the cache
|
|
||||||
// here so search/filter never repeats the group lookup per keystroke.
|
|
||||||
private static string ClassOf(VehicleTexture t) => t.ResolvedClass;
|
private static string ClassOf(VehicleTexture t) => t.ResolvedClass;
|
||||||
|
|
||||||
// Vehicle type = the group's "category" letter (also resolved once at load).
|
|
||||||
private static string? CategoryOf(VehicleTexture t) => t.ResolvedCategory;
|
private static string? CategoryOf(VehicleTexture t) => t.ResolvedCategory;
|
||||||
|
|
||||||
// ------------------------------------------------------------ category combo
|
|
||||||
|
|
||||||
private void PopulateCategoryCombo()
|
private void PopulateCategoryCombo()
|
||||||
{
|
{
|
||||||
// No "All" entry: nothing is selected by default, so the list starts empty
|
|
||||||
// (matching the original Starter's depot).
|
|
||||||
_suppress = true;
|
_suppress = true;
|
||||||
categoryCombo.Items.Clear();
|
categoryCombo.Items.Clear();
|
||||||
foreach (var (locKey, match) in CategoryDefs)
|
foreach (var (locKey, match) in CategoryDefs)
|
||||||
@@ -384,9 +336,6 @@ public partial class Depot : UserControl
|
|||||||
BuildList();
|
BuildList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The dropdown shows the big stacked miniature, the closed combo a one-line
|
|
||||||
// row. Plain "=": SelectionBoxItemTemplate coerces to ItemTemplate while
|
|
||||||
// unset, so "??=" would never assign.
|
|
||||||
private void InitClassComboTemplates()
|
private void InitClassComboTemplates()
|
||||||
{
|
{
|
||||||
classCombo.ItemTemplate = new FuncDataTemplate<string>(
|
classCombo.ItemTemplate = new FuncDataTemplate<string>(
|
||||||
@@ -395,9 +344,6 @@ public partial class Depot : UserControl
|
|||||||
(cls, _) => ClassComboContent(cls, large: false), false);
|
(cls, _) => ClassComboContent(cls, large: false), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fills the class combo for the current category filter; the caller owns the
|
|
||||||
// selection and _classFilter. Runs under _suppress.
|
|
||||||
// No "All" entry: an unselected class means "every class in the category".
|
|
||||||
private void FillClassCombo()
|
private void FillClassCombo()
|
||||||
{
|
{
|
||||||
classCombo.Items.Clear();
|
classCombo.Items.Clear();
|
||||||
@@ -405,8 +351,6 @@ public partial class Depot : UserControl
|
|||||||
classCombo.Items.Add(cls);
|
classCombo.Items.Add(cls);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Points both combos at the given category/class pair without firing their
|
|
||||||
// handlers. Returns false when they already matched.
|
|
||||||
private bool ApplyFilters(string? category, string? cls)
|
private bool ApplyFilters(string? category, string? cls)
|
||||||
{
|
{
|
||||||
var catItem = categoryCombo.Items.OfType<ComboBoxItem>()
|
var catItem = categoryCombo.Items.OfType<ComboBoxItem>()
|
||||||
@@ -433,7 +377,6 @@ public partial class Depot : UserControl
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every texture of a class shares its category.
|
|
||||||
private string? CategoryOfClass(string cls) =>
|
private string? CategoryOfClass(string cls) =>
|
||||||
_db.Textures.FirstOrDefault(t => string.Equals(ClassOf(t), cls, StringComparison.OrdinalIgnoreCase))
|
_db.Textures.FirstOrDefault(t => string.Equals(ClassOf(t), cls, StringComparison.OrdinalIgnoreCase))
|
||||||
is { } t ? CategoryOf(t) : null;
|
is { } t ? CategoryOf(t) : null;
|
||||||
@@ -491,7 +434,6 @@ public partial class Depot : UserControl
|
|||||||
string? cls = classCombo.SelectedItem as string;
|
string? cls = classCombo.SelectedItem as string;
|
||||||
_classFilter = cls;
|
_classFilter = cls;
|
||||||
|
|
||||||
// ApplyFilters refills this very combo, so it cannot run from here.
|
|
||||||
if (cls != null && _categoryFilter == null &&
|
if (cls != null && _categoryFilter == null &&
|
||||||
PostSyncing(() => { ApplyFilters(CategoryOfClass(cls), cls); BuildList(); }))
|
PostSyncing(() => { ApplyFilters(CategoryOfClass(cls), cls); BuildList(); }))
|
||||||
return;
|
return;
|
||||||
@@ -499,9 +441,6 @@ public partial class Depot : UserControl
|
|||||||
BuildList();
|
BuildList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hovering over the Type/Class combos and scrolling cycles the selection
|
|
||||||
// without having to open the dropdown (scroll up = previous, down = next).
|
|
||||||
// When the dropdown is open the wheel scrolls the list as usual.
|
|
||||||
private void Combo_OnPointerWheel(object? sender, PointerWheelEventArgs e)
|
private void Combo_OnPointerWheel(object? sender, PointerWheelEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is not ComboBox combo || combo.IsDropDownOpen)
|
if (sender is not ComboBox combo || combo.IsDropDownOpen)
|
||||||
@@ -512,9 +451,9 @@ public partial class Depot : UserControl
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
int index = combo.SelectedIndex;
|
int index = combo.SelectedIndex;
|
||||||
if (e.Delta.Y > 0) // scroll up -> previous
|
if (e.Delta.Y > 0)
|
||||||
index = index <= 0 ? 0 : index - 1;
|
index = index <= 0 ? 0 : index - 1;
|
||||||
else if (e.Delta.Y < 0) // scroll down -> next
|
else if (e.Delta.Y < 0)
|
||||||
index = index < 0 ? 0 : Math.Min(index + 1, count - 1);
|
index = index < 0 ? 0 : Math.Min(index + 1, count - 1);
|
||||||
else
|
else
|
||||||
return;
|
return;
|
||||||
@@ -523,15 +462,9 @@ public partial class Depot : UserControl
|
|||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------- vehicle browser
|
|
||||||
|
|
||||||
// Flat list of plain-text vehicle entries (no expanders, like the original
|
|
||||||
// Starter). Empty by default: an entry only appears once a category or class
|
|
||||||
// is chosen or a search is typed. Picking one drives the mini preview and the
|
|
||||||
// Add button.
|
|
||||||
private void BuildList()
|
private void BuildList()
|
||||||
{
|
{
|
||||||
// reset selection-dependent UI
|
|
||||||
_browserSelected = null;
|
_browserSelected = null;
|
||||||
miniPreview.Source = null;
|
miniPreview.Source = null;
|
||||||
addVehicleButton.IsEnabled = false;
|
addVehicleButton.IsEnabled = false;
|
||||||
@@ -542,8 +475,6 @@ public partial class Depot : UserControl
|
|||||||
string search = searchBox.Text?.Trim() ?? "";
|
string search = searchBox.Text?.Trim() ?? "";
|
||||||
bool hasSearch = search.Length > 0;
|
bool hasSearch = search.Length > 0;
|
||||||
|
|
||||||
// no filter and nothing searched -> keep the list empty (a class counts
|
|
||||||
// as a filter: it can be picked without a category)
|
|
||||||
if (_categoryFilter == null && _classFilter == null && !hasSearch)
|
if (_categoryFilter == null && _classFilter == null && !hasSearch)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -572,7 +503,6 @@ public partial class Depot : UserControl
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selecting an entry shows its mini preview and enables the Add button.
|
|
||||||
private void VehicleListBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
private void VehicleListBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (vehicleListBox.SelectedItem is ListBoxItem { Tag: VehicleTexture texture })
|
if (vehicleListBox.SelectedItem is ListBoxItem { Tag: VehicleTexture texture })
|
||||||
@@ -630,7 +560,7 @@ public partial class Depot : UserControl
|
|||||||
private void ShowTextureInfo(VehicleTexture texture)
|
private void ShowTextureInfo(VehicleTexture texture)
|
||||||
{
|
{
|
||||||
textureInfoPanel.Children.Clear();
|
textureInfoPanel.Children.Clear();
|
||||||
// The rows are trimmed, so the tooltip carries the full text.
|
|
||||||
var full = new List<string>();
|
var full = new List<string>();
|
||||||
void Row(string label, string? value)
|
void Row(string label, string? value)
|
||||||
{
|
{
|
||||||
@@ -659,29 +589,24 @@ public partial class Depot : UserControl
|
|||||||
ToolTip.SetTip(textureInfoPanel, string.Join("\n", full));
|
ToolTip.SetTip(textureInfoPanel, string.Join("\n", full));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Double-click an entry to replace the selected consist vehicle.
|
|
||||||
private void VehicleListBox_OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e)
|
private void VehicleListBox_OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_browserSelected != null)
|
if (_browserSelected != null)
|
||||||
ReplaceSelected(_browserSelected);
|
ReplaceSelected(_browserSelected);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The single Add button inserts the highlighted vehicle after the selected
|
|
||||||
// consist vehicle (or at the end when nothing is selected).
|
|
||||||
private void AddVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
private void AddVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_browserSelected != null)
|
if (_browserSelected != null)
|
||||||
AddTexture(_browserSelected);
|
AddTexture(_browserSelected);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pascal actAddVehiclesCategory — every skin currently listed in the browser.
|
|
||||||
private void AddAllCategoryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
private void AddAllCategoryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
foreach (var t in CurrentBrowserTextures())
|
foreach (var t in CurrentBrowserTextures())
|
||||||
InsertTextureAt(t, _consist.Count);
|
InsertTextureAt(t, _consist.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pascal actAddVehiclesMMD — first skin per distinct model/MMD.
|
|
||||||
private void AddUniqueMmdButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
private void AddUniqueMmdButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -713,7 +638,6 @@ public partial class Depot : UserControl
|
|||||||
!string.Equals(ClassOf(t), _classFilter, StringComparison.OrdinalIgnoreCase))
|
!string.Equals(ClassOf(t), _classFilter, StringComparison.OrdinalIgnoreCase))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Hide subsequent multi-unit cars; adding the lead expands the whole set.
|
|
||||||
if (_db.IsSetFollower(t))
|
if (_db.IsSetFollower(t))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -734,8 +658,6 @@ public partial class Depot : UserControl
|
|||||||
|| C(t.Meta?.Vehicle) || C(t.Meta?.Operator) || C(ClassOf(t));
|
|| C(t.Meta?.Vehicle) || C(t.Meta?.Operator) || C(ClassOf(t));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replaces the selected consist unit with this vehicle/unit (keeping its
|
|
||||||
// slot, crew and orientation). Falls back to appending if nothing is selected.
|
|
||||||
private void ReplaceSelected(VehicleTexture texture)
|
private void ReplaceSelected(VehicleTexture texture)
|
||||||
{
|
{
|
||||||
if (_selected is null)
|
if (_selected is null)
|
||||||
@@ -767,8 +689,7 @@ public partial class Depot : UserControl
|
|||||||
|
|
||||||
private string BrowserLabel(VehicleTexture texture, IReadOnlyList<VehicleTexture>? set)
|
private string BrowserLabel(VehicleTexture texture, IReadOnlyList<VehicleTexture>? set)
|
||||||
{
|
{
|
||||||
// When texture_mini is missing or only the class/category mini, use the
|
|
||||||
// skin basename so the list doesn't show the category name as the vehicle.
|
|
||||||
string name;
|
string name;
|
||||||
if (!string.IsNullOrEmpty(texture.TextureMini) &&
|
if (!string.IsNullOrEmpty(texture.TextureMini) &&
|
||||||
!string.Equals(texture.TextureMini, texture.ResolvedClass, StringComparison.OrdinalIgnoreCase))
|
!string.Equals(texture.TextureMini, texture.ResolvedClass, StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -783,8 +704,6 @@ public partial class Depot : UserControl
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clicking "Add" inserts after the selected vehicle (or appends). If the
|
|
||||||
// texture belongs to an auto-consist set, the whole set is one locked unit.
|
|
||||||
private void AddTexture(VehicleTexture texture)
|
private void AddTexture(VehicleTexture texture)
|
||||||
{
|
{
|
||||||
int at = _consist.Count;
|
int at = _consist.Count;
|
||||||
@@ -817,8 +736,6 @@ public partial class Depot : UserControl
|
|||||||
RebuildConsist();
|
RebuildConsist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Like the Pascal MatchOccupancy: only powered units get a driver, and only
|
|
||||||
// when the consist has no crew yet (or the unit is placed at the front).
|
|
||||||
private void MatchOccupancy(ConsistItem item, int position)
|
private void MatchOccupancy(ConsistItem item, int position)
|
||||||
{
|
{
|
||||||
item.Driver = eDriverType.Nobody;
|
item.Driver = eDriverType.Nobody;
|
||||||
@@ -839,20 +756,14 @@ public partial class Depot : UserControl
|
|||||||
private static bool IsPoweredCategory(string? c) =>
|
private static bool IsPoweredCategory(string? c) =>
|
||||||
c is "e" or "s" or "p" or "z" or "a";
|
c is "e" or "s" or "p" or "z" or "a";
|
||||||
|
|
||||||
// Every caption here is read from App.Loc when it is built, so a language
|
|
||||||
// switch has to build them again.
|
|
||||||
public void RebuildAfterLanguageChange()
|
public void RebuildAfterLanguageChange()
|
||||||
{
|
{
|
||||||
PopulateCategoryCombo(); // -> class combo -> browser list
|
PopulateCategoryCombo();
|
||||||
PopulateSceneryConsists();
|
PopulateSceneryConsists();
|
||||||
RebuildConsist(); // -> train stats
|
RebuildConsist();
|
||||||
UpdateDetails();
|
UpdateDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- scenery combos
|
|
||||||
|
|
||||||
// Lists the consists of the scenery currently selected in the Scenarios tab,
|
|
||||||
// so they can be picked and edited here.
|
|
||||||
private void PopulateSceneryConsists()
|
private void PopulateSceneryConsists()
|
||||||
{
|
{
|
||||||
_suppress = true;
|
_suppress = true;
|
||||||
@@ -878,7 +789,7 @@ public partial class Depot : UserControl
|
|||||||
Content = ConsistListContent(trainset),
|
Content = ConsistListContent(trainset),
|
||||||
Tag = trainset
|
Tag = trainset
|
||||||
};
|
};
|
||||||
// right-click: warehouse save/load, clipboard copy/paste, clear-all
|
|
||||||
var ts = trainset;
|
var ts = trainset;
|
||||||
entry.ContextMenu = ConsistContextMenu.Build(
|
entry.ContextMenu = ConsistContextMenu.Build(
|
||||||
entry,
|
entry,
|
||||||
@@ -894,7 +805,6 @@ public partial class Depot : UserControl
|
|||||||
_suppress = false;
|
_suppress = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selecting a consist on the map loads it into the editor below.
|
|
||||||
private void SceneryConsistList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
private void SceneryConsistList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_suppress) return;
|
if (_suppress) return;
|
||||||
@@ -911,8 +821,6 @@ public partial class Depot : UserControl
|
|||||||
!string.IsNullOrEmpty(trainset.Description) &&
|
!string.IsNullOrEmpty(trainset.Description) &&
|
||||||
trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
|
trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
|
||||||
|
|
||||||
// Shift+Delete clears the selected scenery consist (same as the context-menu
|
|
||||||
// "remove all vehicles" entry).
|
|
||||||
private void SceneryConsistList_OnKeyDown(object? sender, KeyEventArgs e)
|
private void SceneryConsistList_OnKeyDown(object? sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Key == Key.Delete && e.KeyModifiers.HasFlag(KeyModifiers.Shift) &&
|
if (e.Key == Key.Delete && e.KeyModifiers.HasFlag(KeyModifiers.Shift) &&
|
||||||
@@ -924,9 +832,6 @@ public partial class Depot : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replaces a scenery consist's vehicles (from a warehouse preset or a pasted
|
|
||||||
// clipboard consist), keeping the consist's map slot (name/track/offset), then
|
|
||||||
// loads it into the editor and refreshes the list.
|
|
||||||
private void ApplyConsist(Trainset target, List<Dynamic> vehicles)
|
private void ApplyConsist(Trainset target, List<Dynamic> vehicles)
|
||||||
{
|
{
|
||||||
target.Vehicles = vehicles;
|
target.Vehicles = vehicles;
|
||||||
@@ -936,7 +841,6 @@ public partial class Depot : UserControl
|
|||||||
PopulateSceneryConsists();
|
PopulateSceneryConsists();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clears every vehicle from a scenery consist.
|
|
||||||
private void RemoveAllVehicles(Trainset target)
|
private void RemoveAllVehicles(Trainset target)
|
||||||
{
|
{
|
||||||
target.Vehicles = new List<Dynamic>();
|
target.Vehicles = new List<Dynamic>();
|
||||||
@@ -946,7 +850,6 @@ public partial class Depot : UserControl
|
|||||||
PopulateSceneryConsists();
|
PopulateSceneryConsists();
|
||||||
}
|
}
|
||||||
|
|
||||||
// List caption for a scenery consist — grows with the list width (ellipsis).
|
|
||||||
private static Control ConsistListContent(Trainset trainset) =>
|
private static Control ConsistListContent(Trainset trainset) =>
|
||||||
new TextBlock
|
new TextBlock
|
||||||
{
|
{
|
||||||
@@ -972,8 +875,6 @@ public partial class Depot : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ consist edits
|
|
||||||
|
|
||||||
private Dynamic MakeDynamic(VehicleTexture texture)
|
private Dynamic MakeDynamic(VehicleTexture texture)
|
||||||
{
|
{
|
||||||
string skin = Base(texture.Skinfile);
|
string skin = Base(texture.Skinfile);
|
||||||
@@ -987,8 +888,8 @@ public partial class Depot : UserControl
|
|||||||
MmdFile = string.IsNullOrEmpty(texture.Model) ? skin : texture.Model!,
|
MmdFile = string.IsNullOrEmpty(texture.Model) ? skin : texture.Model!,
|
||||||
Offset = 0f,
|
Offset = 0f,
|
||||||
DriverType = eDriverType.Nobody,
|
DriverType = eDriverType.Nobody,
|
||||||
Coupling = new Coupling { Flags = Coupling.Mechanical | Coupling.BrakePipe }, // 3
|
Coupling = new Coupling { Flags = Coupling.Mechanical | Coupling.BrakePipe },
|
||||||
HasVelocity = true, // emit a canonical "0" velocity in the trainset entry
|
HasVelocity = true,
|
||||||
MiniName = _db.ResolveMiniName(texture)
|
MiniName = _db.ResolveMiniName(texture)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1079,10 +980,9 @@ public partial class Depot : UserControl
|
|||||||
if (ReferenceEquals(_selected, item))
|
if (ReferenceEquals(_selected, item))
|
||||||
SyncStartingVehicle();
|
SyncStartingVehicle();
|
||||||
RebuildConsist();
|
RebuildConsist();
|
||||||
AppState.Instance.NotifyChanged(); // crew change may enable/disable Start
|
AppState.Instance.NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Breaks a grouped unit into individual single-car items.
|
|
||||||
private void SplitItem(ConsistItem item)
|
private void SplitItem(ConsistItem item)
|
||||||
{
|
{
|
||||||
int i = _consist.IndexOf(item);
|
int i = _consist.IndexOf(item);
|
||||||
@@ -1103,8 +1003,6 @@ public partial class Depot : UserControl
|
|||||||
RebuildConsist();
|
RebuildConsist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------- consist render
|
|
||||||
|
|
||||||
private void RebuildConsist()
|
private void RebuildConsist()
|
||||||
{
|
{
|
||||||
consistStack.Children.Clear();
|
consistStack.Children.Clear();
|
||||||
@@ -1112,8 +1010,7 @@ public partial class Depot : UserControl
|
|||||||
{
|
{
|
||||||
var item = _consist[i];
|
var item = _consist[i];
|
||||||
consistStack.Children.Add(BuildCard(item, i));
|
consistStack.Children.Add(BuildCard(item, i));
|
||||||
// Coupler between units, plus a trailing coupler after the last unit
|
|
||||||
// so end-of-train coupling / lights can be set (#10).
|
|
||||||
if (i < _consist.Count - 1)
|
if (i < _consist.Count - 1)
|
||||||
consistStack.Children.Add(BuildCoupler(item));
|
consistStack.Children.Add(BuildCoupler(item));
|
||||||
else
|
else
|
||||||
@@ -1126,9 +1023,6 @@ public partial class Depot : UserControl
|
|||||||
WriteBackToScenery();
|
WriteBackToScenery();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flattens the edited consist back onto the bound scenery trainset so the
|
|
||||||
// change is reflected on the scenery. Driver goes on each unit's lead car;
|
|
||||||
// a flipped unit writes its cars in reverse order.
|
|
||||||
private void WriteBackToScenery()
|
private void WriteBackToScenery()
|
||||||
{
|
{
|
||||||
if (_editingTrainset is null)
|
if (_editingTrainset is null)
|
||||||
@@ -1148,9 +1042,6 @@ public partial class Depot : UserControl
|
|||||||
RefreshConsistListLabels();
|
RefreshConsistListLabels();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolves the .fiz physics for a consist car: the .fiz is named after the
|
|
||||||
// model, so try the database model first, then the scenery mmd token, then the
|
|
||||||
// skin file name.
|
|
||||||
private Physics? PhysicsFor(Dynamic car)
|
private Physics? PhysicsFor(Dynamic car)
|
||||||
{
|
{
|
||||||
string? dbModel = _db.TextureForSkin(car.SkinFile)?.Model;
|
string? dbModel = _db.TextureForSkin(car.SkinFile)?.Model;
|
||||||
@@ -1159,10 +1050,6 @@ public partial class Depot : UserControl
|
|||||||
?? Physics.For(car.DataFolder, car.SkinFile);
|
?? Physics.For(car.DataFolder, car.SkinFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recomputes every coupling from the vehicles' .fiz AllowedFlag, like the
|
|
||||||
// original Starter's AutoCoupler: the coupling is the bits common to the
|
|
||||||
// facing ends, minus the multiple-unit (control) bit when the control types
|
|
||||||
// differ. Called when a vehicle is added or replaced.
|
|
||||||
private void AutoConnectAll()
|
private void AutoConnectAll()
|
||||||
{
|
{
|
||||||
var flat = new List<(Dynamic car, bool flipped)>();
|
var flat = new List<(Dynamic car, bool flipped)>();
|
||||||
@@ -1181,10 +1068,9 @@ public partial class Depot : UserControl
|
|||||||
var lp = PhysicsFor(left);
|
var lp = PhysicsFor(left);
|
||||||
var rp = PhysicsFor(right);
|
var rp = PhysicsFor(right);
|
||||||
|
|
||||||
// GetMaxCoupler: the end facing the neighbour (swapped when flipped)
|
|
||||||
int leftMax = lp == null ? 3 : (lf ? lp.AllowedFlagA : lp.AllowedFlagB);
|
int leftMax = lp == null ? 3 : (lf ? lp.AllowedFlagA : lp.AllowedFlagB);
|
||||||
int rightMax = rp == null ? 3 : (rf ? rp.AllowedFlagB : rp.AllowedFlagA);
|
int rightMax = rp == null ? 3 : (rf ? rp.AllowedFlagB : rp.AllowedFlagA);
|
||||||
int common = leftMax & rightMax; // CommonCoupler = shared flag bits
|
int common = leftMax & rightMax;
|
||||||
|
|
||||||
string lct = lp == null ? "" : (lf ? lp.ControlTypeA : lp.ControlTypeB);
|
string lct = lp == null ? "" : (lf ? lp.ControlTypeA : lp.ControlTypeB);
|
||||||
string rct = rp == null ? "" : (rf ? rp.ControlTypeB : rp.ControlTypeA);
|
string rct = rp == null ? "" : (rf ? rp.ControlTypeB : rp.ControlTypeA);
|
||||||
@@ -1196,7 +1082,6 @@ public partial class Depot : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Train totals — same Pascal mass rules as Scenarios (IncludeVehicleToMass + 200 t retry).
|
|
||||||
private void UpdateTrainStats()
|
private void UpdateTrainStats()
|
||||||
{
|
{
|
||||||
if (_editingTrainset is null)
|
if (_editingTrainset is null)
|
||||||
@@ -1315,16 +1200,13 @@ public partial class Depot : UserControl
|
|||||||
};
|
};
|
||||||
card.PointerPressed += (_, e) =>
|
card.PointerPressed += (_, e) =>
|
||||||
{
|
{
|
||||||
// left button only - right-click is reserved for the context popup
|
|
||||||
if (!e.GetCurrentPoint(card).Properties.IsLeftButtonPressed)
|
if (!e.GetCurrentPoint(card).Properties.IsLeftButtonPressed)
|
||||||
return;
|
return;
|
||||||
// Buttons on the card (split / remove) handle their own clicks.
|
|
||||||
if (e.Source is Control src && src.GetVisualAncestors().OfType<Button>().Any())
|
if (e.Source is Control src && src.GetVisualAncestors().OfType<Button>().Any())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Update selection without RebuildConsist so the pressed card stays
|
|
||||||
// alive for a possible drag. Browser sync waits until click/release
|
|
||||||
// so BuildList does not run under an active drag capture.
|
|
||||||
_selected = item;
|
_selected = item;
|
||||||
SyncStartingVehicle();
|
SyncStartingVehicle();
|
||||||
RefreshCardSelectionChrome();
|
RefreshCardSelectionChrome();
|
||||||
@@ -1337,8 +1219,7 @@ public partial class Depot : UserControl
|
|||||||
DragDrop.SetAllowDrop(card, true);
|
DragDrop.SetAllowDrop(card, true);
|
||||||
card.AddHandler(DragDrop.DragOverEvent, Consist_OnDragOver);
|
card.AddHandler(DragDrop.DragOverEvent, Consist_OnDragOver);
|
||||||
card.AddHandler(DragDrop.DropEvent, (s, e) => ConsistCard_OnDrop(e, index, card));
|
card.AddHandler(DragDrop.DropEvent, (s, e) => ConsistCard_OnDrop(e, index, card));
|
||||||
// right-click a vehicle: wagon number + load actions (copy load from the
|
|
||||||
// previous vehicle, fill to the maximum possible load)
|
|
||||||
card.ContextMenu = BuildCardMenu(card, item);
|
card.ContextMenu = BuildCardMenu(card, item);
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
@@ -1356,16 +1237,12 @@ public partial class Depot : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selects, in the left database browser, the category, class and list entry of
|
|
||||||
// the given vehicle - so clicking a consist car reveals it in the database.
|
|
||||||
private void SelectInBrowser(Dynamic car)
|
private void SelectInBrowser(Dynamic car)
|
||||||
{
|
{
|
||||||
var texture = _db.TextureForSkin(car.SkinFile);
|
var texture = _db.TextureForSkin(car.SkinFile);
|
||||||
if (texture is null)
|
if (texture is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Prefer the set lead so multi-unit followers (hidden in the browser) still
|
|
||||||
// select their lead texture / class.
|
|
||||||
var set = _db.ResolveSet(texture);
|
var set = _db.ResolveSet(texture);
|
||||||
var browserTex = set is { Count: > 0 } ? set[0] : texture;
|
var browserTex = set is { Count: > 0 } ? set[0] : texture;
|
||||||
|
|
||||||
@@ -1377,8 +1254,6 @@ public partial class Depot : UserControl
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors a list pick in the combos, then restores the selection that the
|
|
||||||
// rebuilt list dropped.
|
|
||||||
private void SyncCombosTo(VehicleTexture texture)
|
private void SyncCombosTo(VehicleTexture texture)
|
||||||
{
|
{
|
||||||
if (_syncingCombos || !ApplyFilters(CategoryOf(texture), ClassOf(texture)))
|
if (_syncingCombos || !ApplyFilters(CategoryOf(texture), ClassOf(texture)))
|
||||||
@@ -1387,8 +1262,6 @@ public partial class Depot : UserControl
|
|||||||
PostSyncing(() => { BuildList(); SelectListEntry(texture); });
|
PostSyncing(() => { BuildList(); SelectListEntry(texture); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runs work with the sync flag up, so the selections it makes do not come
|
|
||||||
// back as another sync.
|
|
||||||
private void WhileSyncing(Action work)
|
private void WhileSyncing(Action work)
|
||||||
{
|
{
|
||||||
_syncingCombos = true;
|
_syncingCombos = true;
|
||||||
@@ -1396,8 +1269,6 @@ public partial class Depot : UserControl
|
|||||||
finally { _syncingCombos = false; }
|
finally { _syncingCombos = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same, on the next dispatcher pass: clearing Items from inside a
|
|
||||||
// SelectionChanged throws. False when a sync is already pending.
|
|
||||||
private bool PostSyncing(Action work)
|
private bool PostSyncing(Action work)
|
||||||
{
|
{
|
||||||
if (_syncingCombos)
|
if (_syncingCombos)
|
||||||
@@ -1421,7 +1292,6 @@ public partial class Depot : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The "#<n>" number written into the node name (e.g. EU07-005#11). 0 = none.
|
|
||||||
private static int GetWagonNumber(Dynamic car)
|
private static int GetWagonNumber(Dynamic car)
|
||||||
{
|
{
|
||||||
string name = car.Name ?? "";
|
string name = car.Name ?? "";
|
||||||
@@ -1443,7 +1313,6 @@ public partial class Depot : UserControl
|
|||||||
RebuildConsist();
|
RebuildConsist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small right-click popup to type a wagon number for the vehicle.
|
|
||||||
private void ShowWagonNumberFlyout(Control anchor, Dynamic car)
|
private void ShowWagonNumberFlyout(Control anchor, Dynamic car)
|
||||||
{
|
{
|
||||||
var num = new NumericUpDown
|
var num = new NumericUpDown
|
||||||
@@ -1475,8 +1344,6 @@ public partial class Depot : UserControl
|
|||||||
return item.Cars.Count > 1 ? $"{key} [{item.Cars.Count}]" : key;
|
return item.Cars.Count > 1 ? $"{key} [{item.Cars.Count}]" : key;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coupling indicator between two units (or trailing after the last unit).
|
|
||||||
// Click it to open a flyout with the 8-bit coupling editor for the unit's last car.
|
|
||||||
private Control BuildCoupler(ConsistItem item, bool trailing = false)
|
private Control BuildCoupler(ConsistItem item, bool trailing = false)
|
||||||
{
|
{
|
||||||
var glyph = new TextBlock
|
var glyph = new TextBlock
|
||||||
@@ -1599,7 +1466,6 @@ public partial class Depot : UserControl
|
|||||||
return btn;
|
return btn;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Brakes / Loads editors for the selected unit (applied to all of its cars).
|
|
||||||
private void UpdateDetails()
|
private void UpdateDetails()
|
||||||
{
|
{
|
||||||
brakesPanel.Children.Clear();
|
brakesPanel.Children.Clear();
|
||||||
@@ -1611,7 +1477,6 @@ public partial class Depot : UserControl
|
|||||||
: $"{App.Loc["SelectedVehicle"]}: " +
|
: $"{App.Loc["SelectedVehicle"]}: " +
|
||||||
(_selected.Grouped ? UnitLabel(_selected) : _selected.Cars[0].Name);
|
(_selected.Grouped ? UnitLabel(_selected) : _selected.Cars[0].Name);
|
||||||
|
|
||||||
// whole-consist load tools sit at the top of the Loads tab, always available
|
|
||||||
loadsPanel.Children.Add(BuildConsistLoadTools());
|
loadsPanel.Children.Add(BuildConsistLoadTools());
|
||||||
|
|
||||||
if (_selected is null)
|
if (_selected is null)
|
||||||
@@ -1627,9 +1492,6 @@ public partial class Depot : UserControl
|
|||||||
BuildDamageEditor(_selected);
|
BuildDamageEditor(_selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- load tools
|
|
||||||
|
|
||||||
// The per-vehicle right-click menu: wagon number plus the two load shortcuts.
|
|
||||||
private ContextMenu BuildCardMenu(Control anchor, ConsistItem item)
|
private ContextMenu BuildCardMenu(Control anchor, ConsistItem item)
|
||||||
{
|
{
|
||||||
var menu = new ContextMenu();
|
var menu = new ContextMenu();
|
||||||
@@ -1662,7 +1524,6 @@ public partial class Depot : UserControl
|
|||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Button bar that applies a load operation to every vehicle in the consist.
|
|
||||||
private Control BuildConsistLoadTools()
|
private Control BuildConsistLoadTools()
|
||||||
{
|
{
|
||||||
var panel = new StackPanel { Spacing = 4, Margin = new Thickness(0, 0, 0, 4) };
|
var panel = new StackPanel { Spacing = 4, Margin = new Thickness(0, 0, 0, 4) };
|
||||||
@@ -1687,15 +1548,12 @@ public partial class Depot : UserControl
|
|||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
// True when the unit's lead car can actually carry cargo.
|
|
||||||
private bool IsLoadable(ConsistItem item)
|
private bool IsLoadable(ConsistItem item)
|
||||||
{
|
{
|
||||||
var p = PhysicsFor(item.Cars[0]);
|
var p = PhysicsFor(item.Cars[0]);
|
||||||
return p != null && (p.MaxLoad > 0 || !string.IsNullOrWhiteSpace(p.LoadAccepted));
|
return p != null && (p.MaxLoad > 0 || !string.IsNullOrWhiteSpace(p.LoadAccepted));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The largest cargo this car accepts (.fiz MaxLoad), 1000 as a fallback for a
|
|
||||||
// car that lists accepted cargo but no explicit maximum, 0 when it can't load.
|
|
||||||
private int MaxLoadFor(Dynamic car)
|
private int MaxLoadFor(Dynamic car)
|
||||||
{
|
{
|
||||||
var p = PhysicsFor(car);
|
var p = PhysicsFor(car);
|
||||||
@@ -1704,7 +1562,6 @@ public partial class Depot : UserControl
|
|||||||
return string.IsNullOrWhiteSpace(p.LoadAccepted) ? 0 : 1000;
|
return string.IsNullOrWhiteSpace(p.LoadAccepted) ? 0 : 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cargo types this car accepts (.fiz LoadAccepted), else the global list.
|
|
||||||
private List<string> AcceptedTypesFor(Dynamic car)
|
private List<string> AcceptedTypesFor(Dynamic car)
|
||||||
{
|
{
|
||||||
var p = PhysicsFor(car);
|
var p = PhysicsFor(car);
|
||||||
@@ -1713,8 +1570,6 @@ public partial class Depot : UserControl
|
|||||||
return LoadTypes().ToList();
|
return LoadTypes().ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copies the previous unit's load (type + amount) onto this unit, clamped to
|
|
||||||
// each car's own maximum.
|
|
||||||
private void CopyLoadFromPrevious(ConsistItem item)
|
private void CopyLoadFromPrevious(ConsistItem item)
|
||||||
{
|
{
|
||||||
int idx = _consist.IndexOf(item);
|
int idx = _consist.IndexOf(item);
|
||||||
@@ -1731,8 +1586,6 @@ public partial class Depot : UserControl
|
|||||||
RebuildConsist();
|
RebuildConsist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fills one unit's cars to their maximum load (keeping the cargo type, or
|
|
||||||
// assigning the first accepted one when none is set yet).
|
|
||||||
private void SetUnitMaxLoad(ConsistItem item)
|
private void SetUnitMaxLoad(ConsistItem item)
|
||||||
{
|
{
|
||||||
foreach (var c in item.Cars)
|
foreach (var c in item.Cars)
|
||||||
@@ -1740,7 +1593,6 @@ public partial class Depot : UserControl
|
|||||||
RebuildConsist();
|
RebuildConsist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Random cargo type for every loadable car in the consist, filled to the max.
|
|
||||||
private void RandomLoadTypeForConsist()
|
private void RandomLoadTypeForConsist()
|
||||||
{
|
{
|
||||||
foreach (var c in _consist.SelectMany(u => u.Cars))
|
foreach (var c in _consist.SelectMany(u => u.Cars))
|
||||||
@@ -1756,7 +1608,6 @@ public partial class Depot : UserControl
|
|||||||
RebuildConsist();
|
RebuildConsist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every loadable car in the consist filled to its maximum.
|
|
||||||
private void MaxAmountForConsist()
|
private void MaxAmountForConsist()
|
||||||
{
|
{
|
||||||
foreach (var c in _consist.SelectMany(u => u.Cars))
|
foreach (var c in _consist.SelectMany(u => u.Cars))
|
||||||
@@ -1764,9 +1615,6 @@ public partial class Depot : UserControl
|
|||||||
RebuildConsist();
|
RebuildConsist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Random amount (per car) for every loadable car in the consist, keeping the
|
|
||||||
// cargo type (assigning the first accepted one when none is set). Lets you roll
|
|
||||||
// a different tonnage for each wagon with one click.
|
|
||||||
private void RandomAmountForConsist()
|
private void RandomAmountForConsist()
|
||||||
{
|
{
|
||||||
foreach (var c in _consist.SelectMany(u => u.Cars))
|
foreach (var c in _consist.SelectMany(u => u.Cars))
|
||||||
@@ -1783,7 +1631,6 @@ public partial class Depot : UserControl
|
|||||||
RebuildConsist();
|
RebuildConsist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fills a single car to its maximum load (no-op for non-loadable cars).
|
|
||||||
private void FillToMax(Dynamic car)
|
private void FillToMax(Dynamic car)
|
||||||
{
|
{
|
||||||
int max = MaxLoadFor(car);
|
int max = MaxLoadFor(car);
|
||||||
@@ -1800,7 +1647,6 @@ public partial class Depot : UserControl
|
|||||||
Text = text, Opacity = 0.6, FontSize = 12, TextWrapping = TextWrapping.Wrap
|
Text = text, Opacity = 0.6, FontSize = 12, TextWrapping = TextWrapping.Wrap
|
||||||
};
|
};
|
||||||
|
|
||||||
// Row of "<label> <control>".
|
|
||||||
private static Control LabeledRow(string label, Control control)
|
private static Control LabeledRow(string label, Control control)
|
||||||
{
|
{
|
||||||
var sp = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
|
var sp = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
|
||||||
@@ -1816,7 +1662,6 @@ public partial class Depot : UserControl
|
|||||||
private static void AddOption(ComboBox combo, string content, string? tag)
|
private static void AddOption(ComboBox combo, string content, string? tag)
|
||||||
=> combo.Items.Add(new ComboBoxItem { Content = content, Tag = tag });
|
=> combo.Items.Add(new ComboBoxItem { Content = content, Tag = tag });
|
||||||
|
|
||||||
// Brake mode / load adaptation / switch, written into each car's coupling "B" param.
|
|
||||||
private void BuildBrakesEditor(ConsistItem item)
|
private void BuildBrakesEditor(ConsistItem item)
|
||||||
{
|
{
|
||||||
var brake = item.Cars[0].Coupling.GetBrake();
|
var brake = item.Cars[0].Coupling.GetBrake();
|
||||||
@@ -1863,7 +1708,6 @@ public partial class Depot : UserControl
|
|||||||
brakesPanel.Children.Add(LabeledRow(App.Loc["BrakeSwitch"], switchCombo));
|
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
|
private static string BrakeModeLabel(string code) => code switch
|
||||||
{
|
{
|
||||||
"G" => App.Loc["BrakeFreight"],
|
"G" => App.Loc["BrakeFreight"],
|
||||||
@@ -1893,7 +1737,6 @@ public partial class Depot : UserControl
|
|||||||
_ => App.Loc["None"]
|
_ => App.Loc["None"]
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wheel-damage editor: sway / flat-spot parameters (the "W" coupling prefix).
|
|
||||||
private void BuildDamageEditor(ConsistItem item)
|
private void BuildDamageEditor(ConsistItem item)
|
||||||
{
|
{
|
||||||
var w = item.Cars[0].Coupling.GetWheels() ?? new WheelSettings();
|
var w = item.Cars[0].Coupling.GetWheels() ?? new WheelSettings();
|
||||||
@@ -1962,31 +1805,24 @@ public partial class Depot : UserControl
|
|||||||
damagePanel.Children.Add(grid);
|
damagePanel.Children.Add(grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cargo type + amount, written into each car's node::dynamic trailing params.
|
|
||||||
private void BuildLoadsEditor(ConsistItem item)
|
private void BuildLoadsEditor(ConsistItem item)
|
||||||
{
|
{
|
||||||
var lead = item.Cars[0];
|
var lead = item.Cars[0];
|
||||||
var phys = PhysicsFor(lead);
|
var phys = PhysicsFor(lead);
|
||||||
|
|
||||||
// Prefer the cargo this vehicle actually accepts (.fiz LoadAccepted);
|
|
||||||
// otherwise offer everything from data/load_weights.txt.
|
|
||||||
var available = phys != null && !string.IsNullOrWhiteSpace(phys.LoadAccepted)
|
var available = phys != null && !string.IsNullOrWhiteSpace(phys.LoadAccepted)
|
||||||
? phys.LoadAccepted.Split(',', ';').Select(s => s.Trim()).Where(s => s.Length > 0).ToList()
|
? phys.LoadAccepted.Split(',', ';').Select(s => s.Trim()).Where(s => s.Length > 0).ToList()
|
||||||
: LoadTypes().ToList();
|
: LoadTypes().ToList();
|
||||||
|
|
||||||
// Keep a load already set on the vehicle selectable even when it is not in
|
|
||||||
// the accepted/known list (e.g. a custom load read from the scenery).
|
|
||||||
if (!string.IsNullOrEmpty(lead.LoadType) &&
|
if (!string.IsNullOrEmpty(lead.LoadType) &&
|
||||||
!available.Contains(lead.LoadType!, StringComparer.OrdinalIgnoreCase))
|
!available.Contains(lead.LoadType!, StringComparer.OrdinalIgnoreCase))
|
||||||
available.Insert(0, lead.LoadType!);
|
available.Insert(0, lead.LoadType!);
|
||||||
|
|
||||||
// Dropdown of available cargo: the leading "(none)" entry clears the load.
|
|
||||||
var typeCombo = new ComboBox { FontSize = 12, MinWidth = 200 };
|
var typeCombo = new ComboBox { FontSize = 12, MinWidth = 200 };
|
||||||
typeCombo.Items.Add(new ComboBoxItem { Content = App.Loc["None"], Tag = null });
|
typeCombo.Items.Add(new ComboBoxItem { Content = App.Loc["None"], Tag = null });
|
||||||
foreach (var name in available)
|
foreach (var name in available)
|
||||||
typeCombo.Items.Add(new ComboBoxItem { Content = name, Tag = name });
|
typeCombo.Items.Add(new ComboBoxItem { Content = name, Tag = name });
|
||||||
|
|
||||||
// Pre-select the vehicle's current load (or "(none)").
|
|
||||||
typeCombo.SelectedIndex = 0;
|
typeCombo.SelectedIndex = 0;
|
||||||
if (!string.IsNullOrEmpty(lead.LoadType))
|
if (!string.IsNullOrEmpty(lead.LoadType))
|
||||||
foreach (var obj in typeCombo.Items)
|
foreach (var obj in typeCombo.Items)
|
||||||
@@ -1997,7 +1833,6 @@ public partial class Depot : UserControl
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap the amount at the .fiz MaxLoad when it is defined.
|
|
||||||
int maxLoad = phys != null && phys.MaxLoad > 0 ? phys.MaxLoad : 1000;
|
int maxLoad = phys != null && phys.MaxLoad > 0 ? phys.MaxLoad : 1000;
|
||||||
var countBox = new NumericUpDown
|
var countBox = new NumericUpDown
|
||||||
{
|
{
|
||||||
@@ -2011,16 +1846,14 @@ public partial class Depot : UserControl
|
|||||||
int count = (int)(countBox.Value ?? 0);
|
int count = (int)(countBox.Value ?? 0);
|
||||||
foreach (var c in item.Cars)
|
foreach (var c in item.Cars)
|
||||||
{
|
{
|
||||||
// "(none)" -> no cargo at all; otherwise store the chosen type/amount.
|
|
||||||
c.LoadType = string.IsNullOrEmpty(type) ? null : type;
|
c.LoadType = string.IsNullOrEmpty(type) ? null : type;
|
||||||
c.LoadCount = string.IsNullOrEmpty(type) ? 0 : count;
|
c.LoadCount = string.IsNullOrEmpty(type) ? 0 : count;
|
||||||
if (c.LoadCount > 0)
|
if (c.LoadCount > 0)
|
||||||
c.HasVelocity = true; // velocity token must precede loadcount
|
c.HasVelocity = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire the handlers only after the initial selection so we don't apply
|
|
||||||
// (and mark the consist dirty) while merely populating the editor.
|
|
||||||
typeCombo.SelectionChanged += (_, _) => Apply();
|
typeCombo.SelectionChanged += (_, _) => Apply();
|
||||||
countBox.ValueChanged += (_, _) => Apply();
|
countBox.ValueChanged += (_, _) => Apply();
|
||||||
|
|
||||||
@@ -2028,9 +1861,6 @@ public partial class Depot : UserControl
|
|||||||
loadsPanel.Children.Add(LabeledRow(App.Loc["LoadCount"], countBox));
|
loadsPanel.Children.Add(LabeledRow(App.Loc["LoadCount"], countBox));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cargo names + per-unit weights parsed once from data/load_weights.txt (the
|
|
||||||
// file the simulator and the original Starter use). Each line is
|
|
||||||
// "<name>: <weight>".
|
|
||||||
private static List<string>? _loadTypes;
|
private static List<string>? _loadTypes;
|
||||||
private static Dictionary<string, int>? _loadWeights;
|
private static Dictionary<string, int>? _loadWeights;
|
||||||
|
|
||||||
@@ -2060,7 +1890,7 @@ public partial class Depot : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* missing / unreadable - just no suggestions */ }
|
catch { }
|
||||||
|
|
||||||
_loadTypes = names.Distinct(StringComparer.OrdinalIgnoreCase)
|
_loadTypes = names.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -2074,18 +1904,12 @@ public partial class Depot : UserControl
|
|||||||
return _loadTypes!;
|
return _loadTypes!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-unit weight (kg) of a cargo, defaulting to 1000 like the original Starter.
|
|
||||||
private static int LoadWeight(string? name)
|
private static int LoadWeight(string? name)
|
||||||
{
|
{
|
||||||
EnsureLoads();
|
EnsureLoads();
|
||||||
return !string.IsNullOrEmpty(name) && _loadWeights!.TryGetValue(name!, out int w) ? w : 1000;
|
return !string.IsNullOrEmpty(name) && _loadWeights!.TryGetValue(name!, out int w) ? w : 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------- minis
|
|
||||||
|
|
||||||
// Decodes the miniature down to the requested display height and caches it,
|
|
||||||
// so the same skin is never decoded twice and full-resolution bitmaps never
|
|
||||||
// stay resident in memory.
|
|
||||||
private Bitmap? GetMiniBitmap(string? name, int height)
|
private Bitmap? GetMiniBitmap(string? name, int height)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(name))
|
if (string.IsNullOrEmpty(name))
|
||||||
@@ -2110,8 +1934,6 @@ public partial class Depot : UserControl
|
|||||||
return bmp;
|
return bmp;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ events
|
|
||||||
|
|
||||||
private void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e)
|
private void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_suppress || _searchTimer is null) return;
|
if (_suppress || _searchTimer is null) return;
|
||||||
@@ -2268,8 +2090,7 @@ public partial class Depot : UserControl
|
|||||||
FontWeight = FontWeight.Bold,
|
FontWeight = FontWeight.Bold,
|
||||||
HorizontalAlignment = HorizontalAlignment.Center
|
HorizontalAlignment = HorizontalAlignment.Center
|
||||||
},
|
},
|
||||||
// DockPanel: in a horizontal StackPanel the caption gets infinite
|
|
||||||
// width, never wraps and shoves the spinner off the edge.
|
|
||||||
new DockPanel
|
new DockPanel
|
||||||
{
|
{
|
||||||
LastChildFill = true,
|
LastChildFill = true,
|
||||||
@@ -2402,7 +2223,6 @@ public partial class Depot : UserControl
|
|||||||
private void RandomOrientButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
|
private void RandomOrientButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) =>
|
||||||
RandomizeOrientation();
|
RandomizeOrientation();
|
||||||
|
|
||||||
// Shuffle wagon order from the selected unit to the end (multi-unit groups stay put).
|
|
||||||
private void RandomizeOrder()
|
private void RandomizeOrder()
|
||||||
{
|
{
|
||||||
int start = _selected != null ? _consist.IndexOf(_selected) : 0;
|
int start = _selected != null ? _consist.IndexOf(_selected) : 0;
|
||||||
@@ -2433,7 +2253,6 @@ public partial class Depot : UserControl
|
|||||||
RebuildConsist();
|
RebuildConsist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Randomly flip units from the selected one to the end of the consist.
|
|
||||||
private void RandomizeOrientation()
|
private void RandomizeOrientation()
|
||||||
{
|
{
|
||||||
int start = _selected != null ? _consist.IndexOf(_selected) : 0;
|
int start = _selected != null ? _consist.IndexOf(_selected) : 0;
|
||||||
@@ -2448,8 +2267,6 @@ public partial class Depot : UserControl
|
|||||||
RebuildConsist();
|
RebuildConsist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── drag / drop (browser → consist, reorder inside consist) ────────────
|
|
||||||
|
|
||||||
private void MiniPreview_OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
private void MiniPreview_OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_browserSelected is null || !e.GetCurrentPoint(miniPreviewPanel).Properties.IsLeftButtonPressed)
|
if (_browserSelected is null || !e.GetCurrentPoint(miniPreviewPanel).Properties.IsLeftButtonPressed)
|
||||||
@@ -2461,7 +2278,7 @@ public partial class Depot : UserControl
|
|||||||
{
|
{
|
||||||
if (!e.GetCurrentPoint(vehicleListBox).Properties.IsLeftButtonPressed)
|
if (!e.GetCurrentPoint(vehicleListBox).Properties.IsLeftButtonPressed)
|
||||||
return;
|
return;
|
||||||
// Resolve the row under the pointer so a drag can start before selection settles.
|
|
||||||
var hit = e.Source as Control;
|
var hit = e.Source as Control;
|
||||||
while (hit != null && hit is not ListBoxItem)
|
while (hit != null && hit is not ListBoxItem)
|
||||||
hit = hit.Parent as Control;
|
hit = hit.Parent as Control;
|
||||||
@@ -2514,7 +2331,7 @@ public partial class Depot : UserControl
|
|||||||
var press = _pendingDragPress;
|
var press = _pendingDragPress;
|
||||||
var texture = _pendingDragTexture;
|
var texture = _pendingDragTexture;
|
||||||
int consistIndex = _pendingDragConsistIndex;
|
int consistIndex = _pendingDragConsistIndex;
|
||||||
// Keep browser sync cancelled for the drag session; click path uses it.
|
|
||||||
_pendingBrowserSync = null;
|
_pendingBrowserSync = null;
|
||||||
_pendingDragPress = null;
|
_pendingDragPress = null;
|
||||||
_dragSessionActive = true;
|
_dragSessionActive = true;
|
||||||
@@ -2565,14 +2382,14 @@ public partial class Depot : UserControl
|
|||||||
{
|
{
|
||||||
var sync = _pendingBrowserSync;
|
var sync = _pendingBrowserSync;
|
||||||
ClearPendingDrag();
|
ClearPendingDrag();
|
||||||
// Plain click on a consist card: reveal it in the vehicle browser.
|
|
||||||
if (sync is { Cars.Count: > 0 })
|
if (sync is { Cars.Count: > 0 })
|
||||||
SelectInBrowser(sync.Cars[0]);
|
SelectInBrowser(sync.Cars[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Consist_OnDragOver(object? sender, DragEventArgs e)
|
private void Consist_OnDragOver(object? sender, DragEventArgs e)
|
||||||
{
|
{
|
||||||
// Payload is kept in fields set before DoDragDropAsync; formats are markers.
|
|
||||||
if (_dragTexture != null || _dragConsistIndex >= 0)
|
if (_dragTexture != null || _dragConsistIndex >= 0)
|
||||||
{
|
{
|
||||||
e.DragEffects = _dragConsistIndex >= 0 ? DragDropEffects.Move : DragDropEffects.Copy;
|
e.DragEffects = _dragConsistIndex >= 0 ? DragDropEffects.Move : DragDropEffects.Copy;
|
||||||
@@ -2621,7 +2438,7 @@ public partial class Depot : UserControl
|
|||||||
private void MoveConsistUnit(int from, int to)
|
private void MoveConsistUnit(int from, int to)
|
||||||
{
|
{
|
||||||
if (from < 0 || from >= _consist.Count) return;
|
if (from < 0 || from >= _consist.Count) return;
|
||||||
if (to > from) to--; // account for removal shifting indices
|
if (to > from) to--;
|
||||||
to = Math.Clamp(to, 0, _consist.Count - 1);
|
to = Math.Clamp(to, 0, _consist.Count - 1);
|
||||||
if (from == to) return;
|
if (from == to) return;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public enum MessageBoxButtons
|
|||||||
|
|
||||||
public static class MessageBox
|
public static class MessageBox
|
||||||
{
|
{
|
||||||
/// <summary>Simple Yes/No dialog. Returns true for Yes.</summary>
|
|
||||||
public static async Task<bool> Show(Window owner, string message, string title, MessageBoxButtons _)
|
public static async Task<bool> Show(Window owner, string message, string title, MessageBoxButtons _)
|
||||||
{
|
{
|
||||||
var win = new Window
|
var win = new Window
|
||||||
|
|||||||
@@ -22,26 +22,20 @@ public partial class Scenarios : UserControl
|
|||||||
{
|
{
|
||||||
public List<Scenery> Sceneries;
|
public List<Scenery> Sceneries;
|
||||||
|
|
||||||
// scenery whose weather the form is currently editing
|
|
||||||
private Scenery? _currentScenery;
|
private Scenery? _currentScenery;
|
||||||
// suppresses weather change handlers while the form is being populated
|
|
||||||
private bool _loadingWeather;
|
private bool _loadingWeather;
|
||||||
// "Today" season: the day field only previews the current day-of-year; the
|
|
||||||
// stored Day stays 0 so the scenery keeps using the live date.
|
|
||||||
private bool _todaySeason;
|
private bool _todaySeason;
|
||||||
|
|
||||||
// suppresses the list-filter handlers while their state is restored at startup
|
|
||||||
private bool _loadingFilters;
|
private bool _loadingFilters;
|
||||||
|
|
||||||
public Scenarios()
|
public Scenarios()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
// Sceneries are parsed once at startup (behind the splash).
|
|
||||||
Sceneries = GameData.Instance.Sceneries;
|
Sceneries = GameData.Instance.Sceneries;
|
||||||
|
|
||||||
// Restore the persisted list filters before building the tree/list. Guarded
|
|
||||||
// so assigning IsChecked here doesn't run the rebuild handlers prematurely.
|
|
||||||
_loadingFilters = true;
|
_loadingFilters = true;
|
||||||
showAiCheck.IsChecked = StarterNG.Classes.Settings.Instance.ShowAiVehicles;
|
showAiCheck.IsChecked = StarterNG.Classes.Settings.Instance.ShowAiVehicles;
|
||||||
drivableOnlyCheck.IsChecked = StarterNG.Classes.Settings.Instance.DrivableOnly;
|
drivableOnlyCheck.IsChecked = StarterNG.Classes.Settings.Instance.DrivableOnly;
|
||||||
@@ -51,9 +45,6 @@ public partial class Scenarios : UserControl
|
|||||||
BuildSceneryTree();
|
BuildSceneryTree();
|
||||||
RestoreLastScenery();
|
RestoreLastScenery();
|
||||||
|
|
||||||
// refresh the consist preview when the view is shown again (e.g. after
|
|
||||||
// editing the consist in the depot). Switching tabs only toggles IsVisible
|
|
||||||
// (the view stays in the tree), so listen for that too.
|
|
||||||
AttachedToVisualTree += (_, _) => OnReentered();
|
AttachedToVisualTree += (_, _) => OnReentered();
|
||||||
PropertyChanged += (_, e) =>
|
PropertyChanged += (_, e) =>
|
||||||
{
|
{
|
||||||
@@ -62,20 +53,12 @@ public partial class Scenarios : UserControl
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called whenever this view is shown again (tab switch / re-attach). Edits made
|
|
||||||
// in the depot mutate the same Trainset objects, so refresh both the consist
|
|
||||||
// preview and the vehicle-list captions that depend on them.
|
|
||||||
private void OnReentered()
|
private void OnReentered()
|
||||||
{
|
{
|
||||||
RefreshVehicleLabels();
|
RefreshVehicleLabels();
|
||||||
RefreshSelectedConsist();
|
RefreshSelectedConsist();
|
||||||
}
|
}
|
||||||
|
|
||||||
// (Re)builds the scenery tree, honouring the "show archival" switch. Archival
|
|
||||||
// sceneries (those declaring //$a) are skipped when the switch is off; group
|
|
||||||
// folders that end up empty are omitted. The Tag keeps each node's original
|
|
||||||
// scenery index, so the vehicles list stays aligned. Top-level nodes and group
|
|
||||||
// children are sorted alphabetically.
|
|
||||||
private void BuildSceneryTree()
|
private void BuildSceneryTree()
|
||||||
{
|
{
|
||||||
sceneryList.Items.Clear();
|
sceneryList.Items.Clear();
|
||||||
@@ -136,8 +119,6 @@ public partial class Scenarios : UserControl
|
|||||||
sceneryList.Items.Add(item);
|
sceneryList.Items.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restores the scenery selected last session (starter.last.scenery).
|
|
||||||
// Like Pascal ScenariosList: fall back to the first leaf when nothing matches.
|
|
||||||
private void RestoreLastScenery()
|
private void RestoreLastScenery()
|
||||||
{
|
{
|
||||||
string last = StarterNG.Classes.Settings.Instance.LastScenery;
|
string last = StarterNG.Classes.Settings.Instance.LastScenery;
|
||||||
@@ -208,7 +189,7 @@ public partial class Scenarios : UserControl
|
|||||||
|
|
||||||
private void PersistLastScenery(Scenery scenery)
|
private void PersistLastScenery(Scenery scenery)
|
||||||
{
|
{
|
||||||
// Pascal InitSCN stores the tree node text (//$n or file name).
|
|
||||||
string name = scenery.DisplayName;
|
string name = scenery.DisplayName;
|
||||||
if (string.Equals(StarterNG.Classes.Settings.Instance.LastScenery, name, StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(StarterNG.Classes.Settings.Instance.LastScenery, name, StringComparison.OrdinalIgnoreCase))
|
||||||
return;
|
return;
|
||||||
@@ -228,10 +209,6 @@ public partial class Scenarios : UserControl
|
|||||||
TextWrapping = Avalonia.Media.TextWrapping.NoWrap
|
TextWrapping = Avalonia.Media.TextWrapping.NoWrap
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fills the Vehicles list for a scenery, honouring the two filters:
|
|
||||||
// - "AI vehicles" off hides consists whose //$o description starts with '-';
|
|
||||||
// - "Drivable only" on hides decoration consists (the //$decor flag).
|
|
||||||
// Empty trainsets stay visible (label shows track), matching the Pascal starter.
|
|
||||||
private void PopulateVehicleList(Scenery scn)
|
private void PopulateVehicleList(Scenery scn)
|
||||||
{
|
{
|
||||||
vehicleList.Items.Clear();
|
vehicleList.Items.Clear();
|
||||||
@@ -251,7 +228,7 @@ public partial class Scenarios : UserControl
|
|||||||
Content = TrainsetListContent(trainset),
|
Content = TrainsetListContent(trainset),
|
||||||
Tag = i
|
Tag = i
|
||||||
};
|
};
|
||||||
// right-click: save/load to the warehouse + clipboard copy/paste
|
|
||||||
listItem.ContextMenu = ConsistContextMenu.Build(
|
listItem.ContextMenu = ConsistContextMenu.Build(
|
||||||
listItem,
|
listItem,
|
||||||
() => trainset,
|
() => trainset,
|
||||||
@@ -263,13 +240,10 @@ public partial class Scenarios : UserControl
|
|||||||
vehicleList.SelectedIndex = 0;
|
vehicleList.SelectedIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A computer-driven consist marks its //$o description with a leading '-'.
|
|
||||||
private static bool IsAiTrainset(Trainset trainset) =>
|
private static bool IsAiTrainset(Trainset trainset) =>
|
||||||
!string.IsNullOrEmpty(trainset.Description) &&
|
!string.IsNullOrEmpty(trainset.Description) &&
|
||||||
trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
|
trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal);
|
||||||
|
|
||||||
// AI/player vehicle filter toggled: persist the choice and rebuild the list
|
|
||||||
// for the currently selected scenery.
|
|
||||||
private void VehicleFilter_OnChanged(object? sender, RoutedEventArgs e)
|
private void VehicleFilter_OnChanged(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_loadingFilters) return;
|
if (_loadingFilters) return;
|
||||||
@@ -282,7 +256,6 @@ public partial class Scenarios : UserControl
|
|||||||
PopulateVehicleList(Sceneries[tag]);
|
PopulateVehicleList(Sceneries[tag]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archival-scenery switch toggled: persist the choice and rebuild the tree.
|
|
||||||
private void ArchivalSwitch_OnChanged(object? sender, RoutedEventArgs e)
|
private void ArchivalSwitch_OnChanged(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_loadingFilters) return;
|
if (_loadingFilters) return;
|
||||||
@@ -294,8 +267,6 @@ public partial class Scenarios : UserControl
|
|||||||
RestoreLastScenery();
|
RestoreLastScenery();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replaces a consist's vehicles (from a loaded warehouse preset or a pasted
|
|
||||||
// clipboard consist) and refreshes the row caption / consist preview.
|
|
||||||
private void ApplyConsist(ListBoxItem item, Trainset trainset, List<Dynamic> vehicles)
|
private void ApplyConsist(ListBoxItem item, Trainset trainset, List<Dynamic> vehicles)
|
||||||
{
|
{
|
||||||
trainset.Vehicles = vehicles;
|
trainset.Vehicles = vehicles;
|
||||||
@@ -304,9 +275,6 @@ public partial class Scenarios : UserControl
|
|||||||
ShowConsist(trainset);
|
ShowConsist(trainset);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuilds the caption of each entry in the Vehicles list from its trainset's
|
|
||||||
// current vehicles, so a consist edited in the depot is reflected here too.
|
|
||||||
// Selection and order are preserved (only the text is updated).
|
|
||||||
private void RefreshVehicleLabels()
|
private void RefreshVehicleLabels()
|
||||||
{
|
{
|
||||||
if (sceneryList.SelectedItem is not TreeViewItem { Tag: int tag } || tag >= Sceneries.Count)
|
if (sceneryList.SelectedItem is not TreeViewItem { Tag: int tag } || tag >= Sceneries.Count)
|
||||||
@@ -327,11 +295,11 @@ public partial class Scenarios : UserControl
|
|||||||
{
|
{
|
||||||
foreach (TreeViewItem? toCollapse in sceneryList.Items)
|
foreach (TreeViewItem? toCollapse in sceneryList.Items)
|
||||||
{
|
{
|
||||||
if (toCollapse != null)
|
if (toCollapse != null)
|
||||||
toCollapse.IsExpanded = false;
|
toCollapse.IsExpanded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item != null)
|
if (item != null)
|
||||||
item.IsExpanded = true;
|
item.IsExpanded = true;
|
||||||
if (item is { Items.Count: > 0 })
|
if (item is { Items.Count: > 0 })
|
||||||
sceneryList.SelectedItem = item.Items[0];
|
sceneryList.SelectedItem = item.Items[0];
|
||||||
@@ -339,20 +307,16 @@ public partial class Scenarios : UserControl
|
|||||||
}
|
}
|
||||||
Scenery selectedScn = Sceneries[tag];
|
Scenery selectedScn = Sceneries[tag];
|
||||||
|
|
||||||
// scenery-level info (distinct from the per-consist scenario description)
|
|
||||||
ShowSceneryInfo(selectedScn);
|
ShowSceneryInfo(selectedScn);
|
||||||
ShowWeather(selectedScn);
|
ShowWeather(selectedScn);
|
||||||
PersistLastScenery(selectedScn);
|
PersistLastScenery(selectedScn);
|
||||||
|
|
||||||
// no consist chosen yet for this scenery
|
|
||||||
missionDescription.Text = "";
|
missionDescription.Text = "";
|
||||||
timetableContent.Text = App.Loc["NoTimetable"];
|
timetableContent.Text = App.Loc["NoTimetable"];
|
||||||
|
|
||||||
// add the (filtered) trainsets to the Vehicles list
|
|
||||||
PopulateVehicleList(selectedScn);
|
PopulateVehicleList(selectedScn);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shows the selected scenery's description (//$d) and 1:1 image (//$i).
|
|
||||||
private void ShowSceneryInfo(Scenery scenery)
|
private void ShowSceneryInfo(Scenery scenery)
|
||||||
{
|
{
|
||||||
SceneryI18n.LoadFor(scenery, App.Loc.CurrentLangCode);
|
SceneryI18n.LoadFor(scenery, App.Loc.CurrentLangCode);
|
||||||
@@ -471,10 +435,9 @@ public partial class Scenarios : UserControl
|
|||||||
{
|
{
|
||||||
Process.Start(new ProcessStartInfo(full) { UseShellExecute = true });
|
Process.Start(new ProcessStartInfo(full) { UseShellExecute = true });
|
||||||
}
|
}
|
||||||
catch { /* no handler */ }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Rebuild tree labels after UI / scenery i18n language change.</summary>
|
|
||||||
public void RebuildAfterLanguageChange()
|
public void RebuildAfterLanguageChange()
|
||||||
{
|
{
|
||||||
string? selected = AppState.Instance.CurrentScenery?.Path;
|
string? selected = AppState.Instance.CurrentScenery?.Path;
|
||||||
@@ -487,7 +450,7 @@ public partial class Scenarios : UserControl
|
|||||||
}
|
}
|
||||||
if (AppState.Instance.CurrentScenery != null)
|
if (AppState.Instance.CurrentScenery != null)
|
||||||
ShowSceneryInfo(AppState.Instance.CurrentScenery);
|
ShowSceneryInfo(AppState.Instance.CurrentScenery);
|
||||||
RefreshSelectedConsist(); // stats labels (length / mass / track)
|
RefreshSelectedConsist();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void ReloadSceneries_OnClick(object? sender, RoutedEventArgs e)
|
private async void ReloadSceneries_OnClick(object? sender, RoutedEventArgs e)
|
||||||
@@ -528,19 +491,17 @@ public partial class Scenarios : UserControl
|
|||||||
|
|
||||||
private void VehicleList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
private void VehicleList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
// get selected scenery
|
|
||||||
var tItem = sceneryList.SelectedItem as TreeViewItem;
|
var tItem = sceneryList.SelectedItem as TreeViewItem;
|
||||||
if (tItem?.Tag is not int tTag)
|
if (tItem?.Tag is not int tTag)
|
||||||
return;
|
return;
|
||||||
Scenery selectedScn = Sceneries[tTag];
|
Scenery selectedScn = Sceneries[tTag];
|
||||||
|
|
||||||
// get selected trainset
|
|
||||||
ListBoxItem? vItem = vehicleList.SelectedItem as ListBoxItem;
|
ListBoxItem? vItem = vehicleList.SelectedItem as ListBoxItem;
|
||||||
if (vItem?.Tag is not int vTag)
|
if (vItem?.Tag is not int vTag)
|
||||||
return;
|
return;
|
||||||
Trainset selectedTrainset = selectedScn.Trainsets[vTag];
|
Trainset selectedTrainset = selectedScn.Trainsets[vTag];
|
||||||
|
|
||||||
// share the selection so the depot edits this consist in place
|
|
||||||
AppState.Instance.CurrentScenery = selectedScn;
|
AppState.Instance.CurrentScenery = selectedScn;
|
||||||
AppState.Instance.CurrentTrainset = selectedTrainset;
|
AppState.Instance.CurrentTrainset = selectedTrainset;
|
||||||
AppState.Instance.StartingVehicleName = TrainsetDisplay.DefaultStartingVehicle(selectedTrainset);
|
AppState.Instance.StartingVehicleName = TrainsetDisplay.DefaultStartingVehicle(selectedTrainset);
|
||||||
@@ -549,9 +510,6 @@ public partial class Scenarios : UserControl
|
|||||||
ShowTimetable(selectedScn, selectedTrainset);
|
ShowTimetable(selectedScn, selectedTrainset);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loads the scenery's weather into the editable form (Weather tab). Editing a
|
|
||||||
// control writes the value back onto the scenery and marks it dirty, so the
|
|
||||||
// change is injected into the exported .scn's config block on launch.
|
|
||||||
private void ShowWeather(Scenery scenery)
|
private void ShowWeather(Scenery scenery)
|
||||||
{
|
{
|
||||||
_currentScenery = scenery;
|
_currentScenery = scenery;
|
||||||
@@ -565,7 +523,7 @@ public partial class Scenarios : UserControl
|
|||||||
weatherFog.Value = System.Math.Clamp(scenery.FogEnd, 0, 10000);
|
weatherFog.Value = System.Math.Clamp(scenery.FogEnd, 0, 10000);
|
||||||
SelectByTag(weatherSeason, scenery.Day.ToString());
|
SelectByTag(weatherSeason, scenery.Day.ToString());
|
||||||
SelectByTag(weatherOvercast, scenery.Overcast.ToString(CultureInfo.InvariantCulture));
|
SelectByTag(weatherOvercast, scenery.Overcast.ToString(CultureInfo.InvariantCulture));
|
||||||
// Day 0 maps to the "Today" season entry: preview the live day-of-year.
|
|
||||||
ApplyTodayPreview(scenery.Day == 0);
|
ApplyTodayPreview(scenery.Day == 0);
|
||||||
UpdateWeatherLabels();
|
UpdateWeatherLabels();
|
||||||
}
|
}
|
||||||
@@ -575,7 +533,6 @@ public partial class Scenarios : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reads the form back into the current scenery and flags it for export rewrite.
|
|
||||||
private void CaptureWeather()
|
private void CaptureWeather()
|
||||||
{
|
{
|
||||||
if (_loadingWeather || _currentScenery is null)
|
if (_loadingWeather || _currentScenery is null)
|
||||||
@@ -584,8 +541,7 @@ public partial class Scenarios : UserControl
|
|||||||
var scn = _currentScenery;
|
var scn = _currentScenery;
|
||||||
if (weatherTime.SelectedTime is { } t)
|
if (weatherTime.SelectedTime is { } t)
|
||||||
scn.ScenarioTimeOverride = $"{t.Hours:D2}:{t.Minutes:D2}";
|
scn.ScenarioTimeOverride = $"{t.Hours:D2}:{t.Minutes:D2}";
|
||||||
// In "Today" mode the day field is only a preview, so keep the stored day
|
|
||||||
// at 0 (the scenery then follows the live date).
|
|
||||||
scn.Day = _todaySeason ? 0 : (int)(weatherDay.Value ?? 0);
|
scn.Day = _todaySeason ? 0 : (int)(weatherDay.Value ?? 0);
|
||||||
scn.Temperature = weatherTemp.Value;
|
scn.Temperature = weatherTemp.Value;
|
||||||
scn.FogEnd = (int)weatherFog.Value;
|
scn.FogEnd = (int)weatherFog.Value;
|
||||||
@@ -614,18 +570,14 @@ public partial class Scenarios : UserControl
|
|||||||
combo.SelectedItem = null;
|
combo.SelectedItem = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- weather form event handlers ---
|
|
||||||
private void WeatherTime_OnChanged(object? sender, TimePickerSelectedValueChangedEventArgs e) => CaptureWeather();
|
private void WeatherTime_OnChanged(object? sender, TimePickerSelectedValueChangedEventArgs e) => CaptureWeather();
|
||||||
|
|
||||||
// "Current time" button: drop the live wall-clock time into the picker (the
|
|
||||||
// SelectedTimeChanged handler then captures it onto the scenery).
|
|
||||||
private void WeatherTimeNow_OnClick(object? sender, RoutedEventArgs e)
|
private void WeatherTimeNow_OnClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var now = DateTime.Now;
|
var now = DateTime.Now;
|
||||||
weatherTime.SelectedTime = new TimeSpan(now.Hour, now.Minute, 0);
|
weatherTime.SelectedTime = new TimeSpan(now.Hour, now.Minute, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pascal actRestoreWeather — reload weather fields from the SCN snapshot.
|
|
||||||
private void WeatherRestore_OnClick(object? sender, RoutedEventArgs e)
|
private void WeatherRestore_OnClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_currentScenery is null) return;
|
if (_currentScenery is null) return;
|
||||||
@@ -633,8 +585,6 @@ public partial class Scenarios : UserControl
|
|||||||
ShowWeather(_currentScenery);
|
ShowWeather(_currentScenery);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parses a stored "hh:mm" string into a TimeSpan, or null when it is missing
|
|
||||||
// or malformed (so the picker simply shows no selection).
|
|
||||||
private static TimeSpan? ParseTime(string? text)
|
private static TimeSpan? ParseTime(string? text)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
@@ -647,8 +597,6 @@ public partial class Scenarios : UserControl
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggles "Today" preview: the day field shows the live day-of-year (read-only)
|
|
||||||
// and CaptureWeather keeps the stored Day at 0; any other season re-enables it.
|
|
||||||
private void ApplyTodayPreview(bool today)
|
private void ApplyTodayPreview(bool today)
|
||||||
{
|
{
|
||||||
_todaySeason = today;
|
_todaySeason = today;
|
||||||
@@ -668,7 +616,6 @@ public partial class Scenarios : UserControl
|
|||||||
|
|
||||||
private void WeatherDay_OnChanged(object? sender, NumericUpDownValueChangedEventArgs e) => CaptureWeather();
|
private void WeatherDay_OnChanged(object? sender, NumericUpDownValueChangedEventArgs e) => CaptureWeather();
|
||||||
|
|
||||||
// Picking a season presets the day-of-year, then captures the form.
|
|
||||||
private void WeatherSeason_OnChanged(object? sender, SelectionChangedEventArgs e)
|
private void WeatherSeason_OnChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_loadingWeather)
|
if (_loadingWeather)
|
||||||
@@ -676,7 +623,7 @@ public partial class Scenarios : UserControl
|
|||||||
if ((weatherSeason.SelectedItem as ComboBoxItem)?.Tag is string tag &&
|
if ((weatherSeason.SelectedItem as ComboBoxItem)?.Tag is string tag &&
|
||||||
int.TryParse(tag, out int day))
|
int.TryParse(tag, out int day))
|
||||||
{
|
{
|
||||||
if (day == 0) // "Today" - preview the live day-of-year, keep stored day at 0
|
if (day == 0)
|
||||||
{
|
{
|
||||||
ApplyTodayPreview(true);
|
ApplyTodayPreview(true);
|
||||||
}
|
}
|
||||||
@@ -691,9 +638,6 @@ public partial class Scenarios : UserControl
|
|||||||
CaptureWeather();
|
CaptureWeather();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reads and shows the timetable file referenced by the trainset (its first
|
|
||||||
// "trainset" token is the timetable name). Shows a note when none is set or
|
|
||||||
// the file cannot be found.
|
|
||||||
private void ShowTimetable(Scenery scenery, Trainset trainset)
|
private void ShowTimetable(Scenery scenery, Trainset trainset)
|
||||||
{
|
{
|
||||||
string? path = ResolveTimetablePath(scenery, trainset.Name);
|
string? path = ResolveTimetablePath(scenery, trainset.Name);
|
||||||
@@ -713,15 +657,14 @@ public partial class Scenarios : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Probes the usual locations for a timetable file named after the trainset.
|
|
||||||
private static string? ResolveTimetablePath(Scenery scenery, string? name)
|
private static string? ResolveTimetablePath(Scenery scenery, string? name)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name) ||
|
if (string.IsNullOrWhiteSpace(name) ||
|
||||||
name.Equals("none", System.StringComparison.OrdinalIgnoreCase))
|
name.Equals("none", System.StringComparison.OrdinalIgnoreCase))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
string scnDir = Path.GetDirectoryName(scenery.Path) ?? "."; // scenery/
|
string scnDir = Path.GetDirectoryName(scenery.Path) ?? ".";
|
||||||
string root = Path.GetDirectoryName(scnDir) ?? "."; // MaSzyna root
|
string root = Path.GetDirectoryName(scnDir) ?? ".";
|
||||||
|
|
||||||
var candidates = new[]
|
var candidates = new[]
|
||||||
{
|
{
|
||||||
@@ -734,8 +677,6 @@ public partial class Scenarios : UserControl
|
|||||||
return candidates.FirstOrDefault(File.Exists);
|
return candidates.FirstOrDefault(File.Exists);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renders the consist preview for a trainset (re-reads its current vehicles
|
|
||||||
// so depot edits are reflected).
|
|
||||||
private void ShowConsist(Trainset trainset)
|
private void ShowConsist(Trainset trainset)
|
||||||
{
|
{
|
||||||
consistStack.Children.Clear();
|
consistStack.Children.Clear();
|
||||||
@@ -743,11 +684,11 @@ public partial class Scenarios : UserControl
|
|||||||
var db = GameData.Instance.Vehicles;
|
var db = GameData.Instance.Vehicles;
|
||||||
foreach (var train in trainset.Vehicles)
|
foreach (var train in trainset.Vehicles)
|
||||||
{
|
{
|
||||||
// thumbnail name comes from the matching texture's texture_mini
|
|
||||||
string miniName = db.MiniForSkin(train.SkinFile) ?? train.SkinFile;
|
string miniName = db.MiniForSkin(train.SkinFile) ?? train.SkinFile;
|
||||||
string? path = VehicleDatabase.MiniPath(miniName);
|
string? path = VehicleDatabase.MiniPath(miniName);
|
||||||
if (path is null)
|
if (path is null)
|
||||||
continue; // fallback: no mini
|
continue;
|
||||||
|
|
||||||
int thumbH = StarterNG.Classes.Settings.Instance.LargeThumbnails ? 64 : 32;
|
int thumbH = StarterNG.Classes.Settings.Instance.LargeThumbnails ? 64 : 32;
|
||||||
consistStack.Children.Add(new Image
|
consistStack.Children.Add(new Image
|
||||||
@@ -783,8 +724,6 @@ public partial class Scenarios : UserControl
|
|||||||
car => db.TextureForSkin(car.SkinFile)?.ResolvedCategory);
|
car => db.TextureForSkin(car.SkinFile)?.ResolvedCategory);
|
||||||
}
|
}
|
||||||
|
|
||||||
// When returning to this view, refresh the preview of the selected consist
|
|
||||||
// in case it was modified in the depot.
|
|
||||||
private void RefreshSelectedConsist()
|
private void RefreshSelectedConsist()
|
||||||
{
|
{
|
||||||
if (AppState.Instance.CurrentTrainset is { } trainset)
|
if (AppState.Instance.CurrentTrainset is { } trainset)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ using Avalonia.Markup.Xaml.Styling;
|
|||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using StarterNG.Classes;
|
using StarterNG.Classes;
|
||||||
using StarterNG.Services;
|
using StarterNG.Services;
|
||||||
// Disambiguate from Avalonia.Input.KeyBinding (a command-input binding) imported above.
|
|
||||||
using KeyBinding = StarterNG.Classes.KeyBinding;
|
using KeyBinding = StarterNG.Classes.KeyBinding;
|
||||||
|
|
||||||
namespace StarterNG.Views;
|
namespace StarterNG.Views;
|
||||||
@@ -36,23 +36,17 @@ public partial class Settings : UserControl
|
|||||||
};
|
};
|
||||||
|
|
||||||
FillLanguageCombo();
|
FillLanguageCombo();
|
||||||
// The other picker is in the main window's top bar; both follow whatever
|
|
||||||
// language ends up loaded.
|
|
||||||
App.Loc.LanguageChanged += SelectActiveLanguage;
|
App.Loc.LanguageChanged += SelectActiveLanguage;
|
||||||
|
|
||||||
FeedbackCb.SelectionChanged += FeedbackCb_OnSelectionChanged;
|
FeedbackCb.SelectionChanged += FeedbackCb_OnSelectionChanged;
|
||||||
FpsLimitEnableCb.IsCheckedChanged += (_, _) => FpsLimitSlider.IsEnabled = IsChecked(FpsLimitEnableCb);
|
FpsLimitEnableCb.IsCheckedChanged += (_, _) => FpsLimitSlider.IsEnabled = IsChecked(FpsLimitEnableCb);
|
||||||
|
|
||||||
// The settings instance pulls live values from here whenever the app
|
|
||||||
// closes or the game is launched.
|
|
||||||
StarterNG.Classes.Settings.Instance.CaptureFromUi = ReadFromUi;
|
StarterNG.Classes.Settings.Instance.CaptureFromUi = ReadFromUi;
|
||||||
|
|
||||||
PopulateProfileCombo();
|
PopulateProfileCombo();
|
||||||
ApplyToUi();
|
ApplyToUi();
|
||||||
|
|
||||||
// Controls tab: load the key bindings and build the editor + on-screen
|
|
||||||
// keyboard. The tunnelling handler lets us capture a key press before the
|
|
||||||
// focused button consumes it (Space/Enter would otherwise click it).
|
|
||||||
KeyboardConfig.Instance.Load();
|
KeyboardConfig.Instance.Load();
|
||||||
BuildControlsTab();
|
BuildControlsTab();
|
||||||
AddHandler(KeyDownEvent, OnControlsKeyDown, RoutingStrategies.Tunnel);
|
AddHandler(KeyDownEvent, OnControlsKeyDown, RoutingStrategies.Tunnel);
|
||||||
@@ -130,7 +124,6 @@ public partial class Settings : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Settings instance → controls ──────────────────────────────────────
|
|
||||||
public void ReloadFromSettings() => ApplyToUi();
|
public void ReloadFromSettings() => ApplyToUi();
|
||||||
|
|
||||||
private void ApplyToUi()
|
private void ApplyToUi()
|
||||||
@@ -142,7 +135,6 @@ public partial class Settings : UserControl
|
|||||||
PopulateProfileCombo();
|
PopulateProfileCombo();
|
||||||
PopulateExeCombo();
|
PopulateExeCombo();
|
||||||
|
|
||||||
// General
|
|
||||||
SelectActiveLanguage();
|
SelectActiveLanguage();
|
||||||
FullscreenCb.IsChecked = s.Fullscreen;
|
FullscreenCb.IsChecked = s.Fullscreen;
|
||||||
PauseInactiveCb.IsChecked = s.PauseWhenInactive;
|
PauseInactiveCb.IsChecked = s.PauseWhenInactive;
|
||||||
@@ -151,7 +143,6 @@ public partial class Settings : UserControl
|
|||||||
MouseHorInvertCb.IsChecked = s.InvertMouseHorizontal;
|
MouseHorInvertCb.IsChecked = s.InvertMouseHorizontal;
|
||||||
MouseVertInvertCb.IsChecked = s.InvertMouseVertical;
|
MouseVertInvertCb.IsChecked = s.InvertMouseVertical;
|
||||||
|
|
||||||
// Communication
|
|
||||||
GamepadIgnoreCb.IsChecked = s.IgnoreGamepad;
|
GamepadIgnoreCb.IsChecked = s.IgnoreGamepad;
|
||||||
FeedbackCb.SelectedIndex = s.FeedbackMode;
|
FeedbackCb.SelectedIndex = s.FeedbackMode;
|
||||||
FeedbackPortNud.Value = (decimal)s.FeedbackPort;
|
FeedbackPortNud.Value = (decimal)s.FeedbackPort;
|
||||||
@@ -167,7 +158,6 @@ public partial class Settings : UserControl
|
|||||||
UartRadioChannelCb.IsChecked = s.UartRadioChannel;
|
UartRadioChannelCb.IsChecked = s.UartRadioChannel;
|
||||||
UpdateFeedbackDetailVisibility();
|
UpdateFeedbackDetailVisibility();
|
||||||
|
|
||||||
// Other
|
|
||||||
SelectExeCb.SelectedIndex = s.SelectExeAutomatically
|
SelectExeCb.SelectedIndex = s.SelectExeAutomatically
|
||||||
? 0
|
? 0
|
||||||
: Math.Max(0, FindComboIndexByContent(SelectExeCb, s.ExecutablePath));
|
: Math.Max(0, FindComboIndexByContent(SelectExeCb, s.ExecutablePath));
|
||||||
@@ -175,7 +165,6 @@ public partial class Settings : UserControl
|
|||||||
VirtualShuntingCb.IsChecked = s.VirtualShunting;
|
VirtualShuntingCb.IsChecked = s.VirtualShunting;
|
||||||
LogMissingVehicleFilesCb.IsChecked = s.LogMissingVehicleFiles;
|
LogMissingVehicleFilesCb.IsChecked = s.LogMissingVehicleFiles;
|
||||||
|
|
||||||
// Graphics
|
|
||||||
RenderEngineCb.SelectedIndex = s.RenderEngine;
|
RenderEngineCb.SelectedIndex = s.RenderEngine;
|
||||||
SelectResolution(s.Width, s.Height);
|
SelectResolution(s.Width, s.Height);
|
||||||
bufferScale.Value = s.BufferScalePercent;
|
bufferScale.Value = s.BufferScalePercent;
|
||||||
@@ -210,7 +199,6 @@ public partial class Settings : UserControl
|
|||||||
FullscreenWindowedCb.IsChecked = s.FullscreenWindowed;
|
FullscreenWindowedCb.IsChecked = s.FullscreenWindowed;
|
||||||
RenderAngleVulkanCb.IsChecked = s.RenderAngleVulkan;
|
RenderAngleVulkanCb.IsChecked = s.RenderAngleVulkan;
|
||||||
|
|
||||||
// Physics
|
|
||||||
TrackCurvesSlider.Value = s.SplineFidelity;
|
TrackCurvesSlider.Value = s.SplineFidelity;
|
||||||
PhysicsAccuracyCb.IsChecked = s.FullPhysics;
|
PhysicsAccuracyCb.IsChecked = s.FullPhysics;
|
||||||
PantographBreakCb.IsChecked = s.EnableTraction;
|
PantographBreakCb.IsChecked = s.EnableTraction;
|
||||||
@@ -224,7 +212,6 @@ public partial class Settings : UserControl
|
|||||||
BrakeStepSlider.Value = s.BrakeStep;
|
BrakeStepSlider.Value = s.BrakeStep;
|
||||||
BrakeSpeedSlider.Value = s.BrakeSpeed;
|
BrakeSpeedSlider.Value = s.BrakeSpeed;
|
||||||
|
|
||||||
// Sound
|
|
||||||
EnableSoundsCb.IsChecked = s.SoundEnabled;
|
EnableSoundsCb.IsChecked = s.SoundEnabled;
|
||||||
VolumeSlider.Value = s.Volume;
|
VolumeSlider.Value = s.Volume;
|
||||||
RadioVolumeSlider.Value = s.RadioVolume;
|
RadioVolumeSlider.Value = s.RadioVolume;
|
||||||
@@ -232,7 +219,6 @@ public partial class Settings : UserControl
|
|||||||
PositionalVolumeSlider.Value = s.PositionalVolume;
|
PositionalVolumeSlider.Value = s.PositionalVolume;
|
||||||
AmbientVolumeSlider.Value = s.AmbientVolume;
|
AmbientVolumeSlider.Value = s.AmbientVolume;
|
||||||
|
|
||||||
// Advanced
|
|
||||||
CompressTexturesCb.IsChecked = s.CompressTextures;
|
CompressTexturesCb.IsChecked = s.CompressTextures;
|
||||||
ScaleSpecularsCb.IsChecked = s.ScaleSpeculars;
|
ScaleSpecularsCb.IsChecked = s.ScaleSpeculars;
|
||||||
UseGLESCb.IsChecked = s.UseGLES;
|
UseGLESCb.IsChecked = s.UseGLES;
|
||||||
@@ -244,7 +230,6 @@ public partial class Settings : UserControl
|
|||||||
TrainPhysicsThreadsSlider.Value = s.TrainPhysicsThreads;
|
TrainPhysicsThreadsSlider.Value = s.TrainPhysicsThreads;
|
||||||
IgnoreIrrelevantTrainsCb.IsChecked = s.IgnoreIrrelevantTrains;
|
IgnoreIrrelevantTrainsCb.IsChecked = s.IgnoreIrrelevantTrains;
|
||||||
|
|
||||||
// Starter
|
|
||||||
AutoCloseStarterCb.IsChecked = s.AutoCloseStarter;
|
AutoCloseStarterCb.IsChecked = s.AutoCloseStarter;
|
||||||
LargeThumbnailsCb.IsChecked = s.LargeThumbnails;
|
LargeThumbnailsCb.IsChecked = s.LargeThumbnails;
|
||||||
AutoExpandTreeCb.IsChecked = s.AutoExpandSceneryTree;
|
AutoExpandTreeCb.IsChecked = s.AutoExpandSceneryTree;
|
||||||
@@ -257,14 +242,10 @@ public partial class Settings : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── controls → Settings instance ──────────────────────────────────────
|
|
||||||
private void ReadFromUi()
|
private void ReadFromUi()
|
||||||
{
|
{
|
||||||
var s = StarterNG.Classes.Settings.Instance;
|
var s = StarterNG.Classes.Settings.Instance;
|
||||||
|
|
||||||
// General
|
|
||||||
// Keep the loaded language rather than writing a wrong one over it when
|
|
||||||
// the combo has no valid pick.
|
|
||||||
s.Language = (ChangeLanguageCb.SelectedItem as ComboBoxItem)?.Content?.ToString()
|
s.Language = (ChangeLanguageCb.SelectedItem as ComboBoxItem)?.Content?.ToString()
|
||||||
?? App.Loc.CurrentLanguage;
|
?? App.Loc.CurrentLanguage;
|
||||||
s.Fullscreen = IsChecked(FullscreenCb);
|
s.Fullscreen = IsChecked(FullscreenCb);
|
||||||
@@ -274,7 +255,6 @@ public partial class Settings : UserControl
|
|||||||
s.InvertMouseHorizontal = IsChecked(MouseHorInvertCb);
|
s.InvertMouseHorizontal = IsChecked(MouseHorInvertCb);
|
||||||
s.InvertMouseVertical = IsChecked(MouseVertInvertCb);
|
s.InvertMouseVertical = IsChecked(MouseVertInvertCb);
|
||||||
|
|
||||||
// Communication
|
|
||||||
s.IgnoreGamepad = IsChecked(GamepadIgnoreCb);
|
s.IgnoreGamepad = IsChecked(GamepadIgnoreCb);
|
||||||
s.FeedbackMode = Math.Max(0, FeedbackCb.SelectedIndex);
|
s.FeedbackMode = Math.Max(0, FeedbackCb.SelectedIndex);
|
||||||
s.FeedbackPort = (int)(FeedbackPortNud.Value ?? 888);
|
s.FeedbackPort = (int)(FeedbackPortNud.Value ?? 888);
|
||||||
@@ -289,7 +269,6 @@ public partial class Settings : UserControl
|
|||||||
s.UartRadioVolume = IsChecked(UartRadioVolumeCb);
|
s.UartRadioVolume = IsChecked(UartRadioVolumeCb);
|
||||||
s.UartRadioChannel = IsChecked(UartRadioChannelCb);
|
s.UartRadioChannel = IsChecked(UartRadioChannelCb);
|
||||||
|
|
||||||
// Other
|
|
||||||
s.SelectExeAutomatically = SelectExeCb.SelectedIndex == 0;
|
s.SelectExeAutomatically = SelectExeCb.SelectedIndex == 0;
|
||||||
s.ExecutablePath = s.SelectExeAutomatically ? "eu07.exe"
|
s.ExecutablePath = s.SelectExeAutomatically ? "eu07.exe"
|
||||||
: (SelectExeCb.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "eu07.exe";
|
: (SelectExeCb.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "eu07.exe";
|
||||||
@@ -297,7 +276,6 @@ public partial class Settings : UserControl
|
|||||||
s.VirtualShunting = IsChecked(VirtualShuntingCb);
|
s.VirtualShunting = IsChecked(VirtualShuntingCb);
|
||||||
s.LogMissingVehicleFiles = IsChecked(LogMissingVehicleFilesCb);
|
s.LogMissingVehicleFiles = IsChecked(LogMissingVehicleFilesCb);
|
||||||
|
|
||||||
// Graphics
|
|
||||||
s.RenderEngine = Math.Max(0, RenderEngineCb.SelectedIndex);
|
s.RenderEngine = Math.Max(0, RenderEngineCb.SelectedIndex);
|
||||||
ReadResolution(s);
|
ReadResolution(s);
|
||||||
s.BufferScalePercent = (int)bufferScale.Value;
|
s.BufferScalePercent = (int)bufferScale.Value;
|
||||||
@@ -332,7 +310,6 @@ public partial class Settings : UserControl
|
|||||||
s.FullscreenWindowed = IsChecked(FullscreenWindowedCb);
|
s.FullscreenWindowed = IsChecked(FullscreenWindowedCb);
|
||||||
s.RenderAngleVulkan = IsChecked(RenderAngleVulkanCb);
|
s.RenderAngleVulkan = IsChecked(RenderAngleVulkanCb);
|
||||||
|
|
||||||
// Physics
|
|
||||||
s.SplineFidelity = (int)TrackCurvesSlider.Value;
|
s.SplineFidelity = (int)TrackCurvesSlider.Value;
|
||||||
s.FullPhysics = IsChecked(PhysicsAccuracyCb);
|
s.FullPhysics = IsChecked(PhysicsAccuracyCb);
|
||||||
s.EnableTraction = IsChecked(PantographBreakCb);
|
s.EnableTraction = IsChecked(PantographBreakCb);
|
||||||
@@ -346,7 +323,6 @@ public partial class Settings : UserControl
|
|||||||
s.BrakeStep = BrakeStepSlider.Value;
|
s.BrakeStep = BrakeStepSlider.Value;
|
||||||
s.BrakeSpeed = BrakeSpeedSlider.Value;
|
s.BrakeSpeed = BrakeSpeedSlider.Value;
|
||||||
|
|
||||||
// Sound
|
|
||||||
s.SoundEnabled = IsChecked(EnableSoundsCb);
|
s.SoundEnabled = IsChecked(EnableSoundsCb);
|
||||||
s.Volume = (int)VolumeSlider.Value;
|
s.Volume = (int)VolumeSlider.Value;
|
||||||
s.RadioVolume = (int)RadioVolumeSlider.Value;
|
s.RadioVolume = (int)RadioVolumeSlider.Value;
|
||||||
@@ -354,7 +330,6 @@ public partial class Settings : UserControl
|
|||||||
s.PositionalVolume = (int)PositionalVolumeSlider.Value;
|
s.PositionalVolume = (int)PositionalVolumeSlider.Value;
|
||||||
s.AmbientVolume = (int)AmbientVolumeSlider.Value;
|
s.AmbientVolume = (int)AmbientVolumeSlider.Value;
|
||||||
|
|
||||||
// Advanced
|
|
||||||
s.CompressTextures = IsChecked(CompressTexturesCb);
|
s.CompressTextures = IsChecked(CompressTexturesCb);
|
||||||
s.ScaleSpeculars = IsChecked(ScaleSpecularsCb);
|
s.ScaleSpeculars = IsChecked(ScaleSpecularsCb);
|
||||||
s.UseGLES = IsChecked(UseGLESCb);
|
s.UseGLES = IsChecked(UseGLESCb);
|
||||||
@@ -366,7 +341,6 @@ public partial class Settings : UserControl
|
|||||||
s.TrainPhysicsThreads = (int)TrainPhysicsThreadsSlider.Value;
|
s.TrainPhysicsThreads = (int)TrainPhysicsThreadsSlider.Value;
|
||||||
s.IgnoreIrrelevantTrains = IsChecked(IgnoreIrrelevantTrainsCb);
|
s.IgnoreIrrelevantTrains = IsChecked(IgnoreIrrelevantTrainsCb);
|
||||||
|
|
||||||
// Starter
|
|
||||||
s.AutoCloseStarter = IsChecked(AutoCloseStarterCb);
|
s.AutoCloseStarter = IsChecked(AutoCloseStarterCb);
|
||||||
s.LargeThumbnails = IsChecked(LargeThumbnailsCb);
|
s.LargeThumbnails = IsChecked(LargeThumbnailsCb);
|
||||||
s.AutoExpandSceneryTree = IsChecked(AutoExpandTreeCb);
|
s.AutoExpandSceneryTree = IsChecked(AutoExpandTreeCb);
|
||||||
@@ -395,7 +369,6 @@ public partial class Settings : UserControl
|
|||||||
SaveStatus.Text = string.Empty;
|
SaveStatus.Text = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── resolution combo helpers ──────────────────────────────────────────
|
|
||||||
private void SelectResolution(int width, int height)
|
private void SelectResolution(int width, int height)
|
||||||
{
|
{
|
||||||
string target = $"{width}x{height}";
|
string target = $"{width}x{height}";
|
||||||
@@ -407,7 +380,7 @@ public partial class Settings : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Not in the predefined list: add and select it.
|
|
||||||
var added = new ComboBoxItem { Content = target };
|
var added = new ComboBoxItem { Content = target };
|
||||||
ResolutionCb.Items.Add(added);
|
ResolutionCb.Items.Add(added);
|
||||||
ResolutionCb.SelectedItem = added;
|
ResolutionCb.SelectedItem = added;
|
||||||
@@ -427,7 +400,6 @@ public partial class Settings : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── existing slider readouts ──────────────────────────────────────────
|
|
||||||
private void TextureResolutionSlider_OnValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
private void TextureResolutionSlider_OnValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (texResolution is null || textureResolutionSlider is null)
|
if (texResolution is null || textureResolutionSlider is null)
|
||||||
@@ -455,8 +427,6 @@ public partial class Settings : UserControl
|
|||||||
shaderResolution.Text = $"{resolution} px";
|
shaderResolution.Text = $"{resolution} px";
|
||||||
}
|
}
|
||||||
|
|
||||||
// One entry per file in the lang/ folder, so every translation is reachable
|
|
||||||
// (the list used to be a hard-coded Polski / English pair).
|
|
||||||
private void FillLanguageCombo()
|
private void FillLanguageCombo()
|
||||||
{
|
{
|
||||||
ChangeLanguageCb.Items.Clear();
|
ChangeLanguageCb.Items.Clear();
|
||||||
@@ -467,8 +437,6 @@ public partial class Settings : UserControl
|
|||||||
SelectActiveLanguage();
|
SelectActiveLanguage();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Points the combo at the language currently loaded. The selection change
|
|
||||||
// lands in the handler below, which sees it is already loaded and stops.
|
|
||||||
private void SelectActiveLanguage()
|
private void SelectActiveLanguage()
|
||||||
{
|
{
|
||||||
var item = ChangeLanguageCb.Items.OfType<ComboBoxItem>()
|
var item = ChangeLanguageCb.Items.OfType<ComboBoxItem>()
|
||||||
@@ -499,7 +467,6 @@ public partial class Settings : UserControl
|
|||||||
UartExpander.IsVisible = FeedbackCb.SelectedIndex == 5;
|
UartExpander.IsVisible = FeedbackCb.SelectedIndex == 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── tiny helpers ──────────────────────────────────────────────────────
|
|
||||||
private static bool IsChecked(CheckBox cb) => cb.IsChecked == true;
|
private static bool IsChecked(CheckBox cb) => cb.IsChecked == true;
|
||||||
|
|
||||||
private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v);
|
private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v);
|
||||||
@@ -517,29 +484,20 @@ public partial class Settings : UserControl
|
|||||||
for (int i = 0; i < steps.Length; i++)
|
for (int i = 0; i < steps.Length; i++)
|
||||||
if (steps[i] == anisotropy)
|
if (steps[i] == anisotropy)
|
||||||
return i + 1;
|
return i + 1;
|
||||||
return 4; // default → 8x
|
return 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
// Controls tab — key-binding editor and on-screen keyboard
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
private TextBox? _controlsSearch;
|
private TextBox? _controlsSearch;
|
||||||
private StackPanel? _bindingListPanel;
|
private StackPanel? _bindingListPanel;
|
||||||
private StackPanel? _keyboardPanel;
|
private StackPanel? _keyboardPanel;
|
||||||
|
|
||||||
// Binding currently waiting for a key press, and the button that launched it.
|
|
||||||
private KeyBinding? _capturing;
|
private KeyBinding? _capturing;
|
||||||
private Button? _capturingButton;
|
private Button? _capturingButton;
|
||||||
|
|
||||||
// On-screen key cells, keyed by token, so colours can be refreshed in place
|
|
||||||
// (rebuilding the whole keyboard would detach an open assignment flyout).
|
|
||||||
private readonly Dictionary<string, Grid> _keyCells = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, Grid> _keyCells = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Guards the assignment flyout's combo boxes against feedback during programmatic updates.
|
|
||||||
private bool _flyoutLoading;
|
private bool _flyoutLoading;
|
||||||
|
|
||||||
// State colours (match the legend): unassigned/filler, plain, +shift, +ctrl.
|
|
||||||
private static readonly IBrush KeyUnassignedBrush = new SolidColorBrush(Color.Parse("#2A3036"));
|
private static readonly IBrush KeyUnassignedBrush = new SolidColorBrush(Color.Parse("#2A3036"));
|
||||||
private static readonly IBrush KeyFillerBrush = new SolidColorBrush(Color.Parse("#21262B"));
|
private static readonly IBrush KeyFillerBrush = new SolidColorBrush(Color.Parse("#21262B"));
|
||||||
private static readonly IBrush KeyPlainBrush = new SolidColorBrush(Color.Parse("#2E9E1F"));
|
private static readonly IBrush KeyPlainBrush = new SolidColorBrush(Color.Parse("#2E9E1F"));
|
||||||
@@ -550,7 +508,7 @@ public partial class Settings : UserControl
|
|||||||
private static readonly IBrush FgDimBrush = new SolidColorBrush(Color.Parse("#9098A0"));
|
private static readonly IBrush FgDimBrush = new SolidColorBrush(Color.Parse("#9098A0"));
|
||||||
private static readonly IBrush ConflictBrush = new SolidColorBrush(Color.Parse("#E06C5A"));
|
private static readonly IBrush ConflictBrush = new SolidColorBrush(Color.Parse("#E06C5A"));
|
||||||
|
|
||||||
private const double KeyUnit = 30; // px per relative width unit
|
private const double KeyUnit = 30;
|
||||||
private const double KeyHeight = 34;
|
private const double KeyHeight = 34;
|
||||||
private const double KeyGap = 4;
|
private const double KeyGap = 4;
|
||||||
|
|
||||||
@@ -559,7 +517,6 @@ public partial class Settings : UserControl
|
|||||||
if (ControlsHost is null)
|
if (ControlsHost is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Row 0 — search / restore bar.
|
|
||||||
var topBar = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Thickness(0, 0, 0, 8) };
|
var topBar = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Thickness(0, 0, 0, 8) };
|
||||||
topBar.Children.Add(new TextBlock
|
topBar.Children.Add(new TextBlock
|
||||||
{
|
{
|
||||||
@@ -585,7 +542,6 @@ public partial class Settings : UserControl
|
|||||||
Grid.SetRow(topBar, 0);
|
Grid.SetRow(topBar, 0);
|
||||||
ControlsHost.Children.Add(topBar);
|
ControlsHost.Children.Add(topBar);
|
||||||
|
|
||||||
// Row 1 — scrollable binding list.
|
|
||||||
_bindingListPanel = new StackPanel { Spacing = 3 };
|
_bindingListPanel = new StackPanel { Spacing = 3 };
|
||||||
var listScroll = new ScrollViewer
|
var listScroll = new ScrollViewer
|
||||||
{
|
{
|
||||||
@@ -595,7 +551,6 @@ public partial class Settings : UserControl
|
|||||||
Grid.SetRow(listScroll, 1);
|
Grid.SetRow(listScroll, 1);
|
||||||
ControlsHost.Children.Add(listScroll);
|
ControlsHost.Children.Add(listScroll);
|
||||||
|
|
||||||
// Row 2 — legend + on-screen keyboard.
|
|
||||||
var bottom = new StackPanel { Spacing = 8, Margin = new Thickness(0, 10, 0, 0) };
|
var bottom = new StackPanel { Spacing = 8, Margin = new Thickness(0, 10, 0, 0) };
|
||||||
bottom.Children.Add(BuildLegend());
|
bottom.Children.Add(BuildLegend());
|
||||||
_keyboardPanel = new StackPanel();
|
_keyboardPanel = new StackPanel();
|
||||||
@@ -612,7 +567,6 @@ public partial class Settings : UserControl
|
|||||||
RebuildKeyboard();
|
RebuildKeyboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── binding list ───────────────────────────────────────────────────────
|
|
||||||
private void RebuildBindingList()
|
private void RebuildBindingList()
|
||||||
{
|
{
|
||||||
if (_bindingListPanel is null)
|
if (_bindingListPanel is null)
|
||||||
@@ -707,17 +661,16 @@ public partial class Settings : UserControl
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── key capture ──────────────────────────────────────────────────────--
|
|
||||||
private void StartCapture(KeyBinding b, Button button)
|
private void StartCapture(KeyBinding b, Button button)
|
||||||
{
|
{
|
||||||
// Restore the previously listening button, if any, before switching.
|
|
||||||
if (_capturing is not null && _capturingButton is not null)
|
if (_capturing is not null && _capturingButton is not null)
|
||||||
_capturingButton.Content = ComboText(_capturing);
|
_capturingButton.Content = ComboText(_capturing);
|
||||||
|
|
||||||
_capturing = b;
|
_capturing = b;
|
||||||
_capturingButton = button;
|
_capturingButton = button;
|
||||||
button.Content = App.Loc["PressKey"];
|
button.Content = App.Loc["PressKey"];
|
||||||
button.Focus(); // keep focus inside the view so the tunnel handler sees keys
|
button.Focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CancelCapture()
|
private void CancelCapture()
|
||||||
@@ -740,14 +693,13 @@ public partial class Settings : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for a real key while only modifiers are held.
|
|
||||||
if (KeyMap.IsModifierKey(e.Key))
|
if (KeyMap.IsModifierKey(e.Key))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
string? token = KeyMap.FromInput(e.Key, e.PhysicalKey);
|
string? token = KeyMap.FromInput(e.Key, e.PhysicalKey);
|
||||||
if (token is null)
|
if (token is null)
|
||||||
{
|
{
|
||||||
e.Handled = true; // swallow unsupported keys instead of clicking the button
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,7 +715,6 @@ public partial class Settings : UserControl
|
|||||||
RebuildKeyboard();
|
RebuildKeyboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── on-screen keyboard ───────────────────────────────────────────────--
|
|
||||||
private void RebuildKeyboard()
|
private void RebuildKeyboard()
|
||||||
{
|
{
|
||||||
if (_keyboardPanel is null)
|
if (_keyboardPanel is null)
|
||||||
@@ -780,15 +731,13 @@ public partial class Settings : UserControl
|
|||||||
_keyboardPanel.Children.Add(row);
|
_keyboardPanel.Children.Add(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recolours every key in place without rebuilding the tree, so an open
|
|
||||||
// assignment flyout keeps its anchor.
|
|
||||||
private void UpdateKeyboardColors()
|
private void UpdateKeyboardColors()
|
||||||
{
|
{
|
||||||
var states = ComputeKeyStates();
|
var states = ComputeKeyStates();
|
||||||
foreach (var (token, content) in _keyCells)
|
foreach (var (token, content) in _keyCells)
|
||||||
{
|
{
|
||||||
if (content.Children.Count > 0)
|
if (content.Children.Count > 0)
|
||||||
content.Children.RemoveAt(0); // drop the old background, keep the label
|
content.Children.RemoveAt(0);
|
||||||
content.Children.Insert(0, BuildKeyBackground(token, states));
|
content.Children.Insert(0, BuildKeyBackground(token, states));
|
||||||
states.TryGetValue(token, out var state);
|
states.TryGetValue(token, out var state);
|
||||||
ToolTip.SetTip(content, BuildKeyTooltip(token, state));
|
ToolTip.SetTip(content, BuildKeyTooltip(token, state));
|
||||||
@@ -824,7 +773,7 @@ public partial class Settings : UserControl
|
|||||||
|
|
||||||
if (cap.Token is null)
|
if (cap.Token is null)
|
||||||
{
|
{
|
||||||
// Reserved key (Esc, F1-F12, modifiers …): greyed out and not editable.
|
|
||||||
content.Children.Add(new Border { Background = KeyFillerBrush });
|
content.Children.Add(new Border { Background = KeyFillerBrush });
|
||||||
content.Children.Add(new TextBlock
|
content.Children.Add(new TextBlock
|
||||||
{
|
{
|
||||||
@@ -885,7 +834,6 @@ public partial class Settings : UserControl
|
|||||||
return head + "\n" + string.Join("\n", state.Tips);
|
return head + "\n" + string.Join("\n", state.Tips);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── key assignment flyout (one combo per modifier combination) ──────────
|
|
||||||
private void ShowKeyFlyout(string token, Control anchor)
|
private void ShowKeyFlyout(string token, Control anchor)
|
||||||
{
|
{
|
||||||
CancelCapture();
|
CancelCapture();
|
||||||
@@ -987,7 +935,6 @@ public partial class Settings : UserControl
|
|||||||
{
|
{
|
||||||
KeyBinding? chosen = selectedIndex <= 0 ? null : commands[selectedIndex - 1];
|
KeyBinding? chosen = selectedIndex <= 0 ? null : commands[selectedIndex - 1];
|
||||||
|
|
||||||
// Free the slot: unbind any other command currently sitting on it.
|
|
||||||
foreach (var b in KeyboardConfig.Instance.Bindings)
|
foreach (var b in KeyboardConfig.Instance.Bindings)
|
||||||
{
|
{
|
||||||
if (!ReferenceEquals(b, chosen) && b.IsAssigned &&
|
if (!ReferenceEquals(b, chosen) && b.IsAssigned &&
|
||||||
@@ -1048,7 +995,6 @@ public partial class Settings : UserControl
|
|||||||
return sp;
|
return sp;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── shared helpers ─────────────────────────────────────────────────────
|
|
||||||
private sealed class KeyState
|
private sealed class KeyState
|
||||||
{
|
{
|
||||||
public bool Plain;
|
public bool Plain;
|
||||||
@@ -1106,8 +1052,6 @@ public partial class Settings : UserControl
|
|||||||
return string.Join(" + ", parts);
|
return string.Join(" + ", parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display label for a command: its (capitalised) description, or the raw
|
|
||||||
// command token when the file has no comment for it.
|
|
||||||
private static string CommandLabel(KeyBinding b) =>
|
private static string CommandLabel(KeyBinding b) =>
|
||||||
string.IsNullOrEmpty(b.Description) ? b.Command : Capitalize(b.Description);
|
string.IsNullOrEmpty(b.Description) ? b.Command : Capitalize(b.Description);
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ public partial class TextureBaseWindow : Window
|
|||||||
private readonly VehicleDatabase _db = GameData.Instance.Vehicles;
|
private readonly VehicleDatabase _db = GameData.Instance.Vehicles;
|
||||||
private readonly List<Row> _all = new();
|
private readonly List<Row> _all = new();
|
||||||
|
|
||||||
/// <summary>Texture chosen by double-click (null if the window was just closed).</summary>
|
|
||||||
public VehicleTexture? Picked { get; private set; }
|
public VehicleTexture? Picked { get; private set; }
|
||||||
|
|
||||||
public TextureBaseWindow()
|
public TextureBaseWindow()
|
||||||
|
|||||||
Reference in New Issue
Block a user