diff --git a/StarterNG/App.axaml.cs b/StarterNG/App.axaml.cs index 6b5d2e0..5485232 100644 --- a/StarterNG/App.axaml.cs +++ b/StarterNG/App.axaml.cs @@ -1,10 +1,14 @@ using System; using System.Globalization; using System.Linq; +using System.Text; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml.Styling; +using Avalonia.Threading; +using StarterNG.Classes; using StarterNG.Services; namespace StarterNG; @@ -54,7 +58,30 @@ public partial class App : Application { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - desktop.MainWindow = new MainWindow(); + // Code page 1250 is needed to parse scenery files (done during load). + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + var splash = new SplashWindow(); + desktop.MainWindow = splash; + splash.Show(); + + // Progress marshals callbacks back to this (UI) thread. + var progress = new Progress(splash.Report); + + Task.Run(async () => + { + GameData.Instance.Load(progress); + await Task.Delay(400); // let the completed bar be visible briefly + }).ContinueWith(_ => + { + Dispatcher.UIThread.Post(() => + { + var main = new MainWindow(); + desktop.MainWindow = main; // keep a main window before closing the splash + main.Show(); + splash.Close(); + }); + }); } base.OnFrameworkInitializationCompleted(); diff --git a/StarterNG/Assets/Langs/English.axaml b/StarterNG/Assets/Langs/English.axaml index 9b65a09..e3ce747 100644 --- a/StarterNG/Assets/Langs/English.axaml +++ b/StarterNG/Assets/Langs/English.axaml @@ -33,11 +33,36 @@ DMU Carriages A Carriages B + Electric locomotive + Diesel locomotive + Steam locomotive + Railbus + Draisine + Work vehicles + Prototype + Tram + Car + Bus + Truck + People + Animals + Wagons + Other Brakes Loads Coupling Select a vehicle in the consist to edit it. Editing options coming soon. + Split + Split the unit into individual cars + Add + Add this vehicle to the consist + Add the whole unit to the consist + + + Loading vehicle database + Loading sceneries + Starting… General diff --git a/StarterNG/Assets/Langs/Polski.axaml b/StarterNG/Assets/Langs/Polski.axaml index 290e810..322de8e 100644 --- a/StarterNG/Assets/Langs/Polski.axaml +++ b/StarterNG/Assets/Langs/Polski.axaml @@ -33,11 +33,36 @@ SZT Wagony A Wagony B + Lokomotywa elektryczna + Lokomotywa spalinowa + Parowóz + Szynobus + Drezyna + Pojazdy robocze + Prototyp + Tramwaj + Samochód osobowy + Autobus + Samochód ciężarowy + Ludzie + Zwierzęta + Wagony + Inne Hamulce Ładunki Sprzęg Wybierz pojazd w składzie, aby go edytować. Opcje edycji wkrótce. + Rozdziel + Rozdziel zespół na pojedyncze człony + Dodaj + Dodaj ten pojazd do składu + Dodaj cały zespół do składu + + + Wczytywanie bazy pojazdów + Wczytywanie scenerii + Uruchamianie… Ogólne diff --git a/StarterNG/Classes/AppState.cs b/StarterNG/Classes/AppState.cs new file mode 100644 index 0000000..fbbdf0a --- /dev/null +++ b/StarterNG/Classes/AppState.cs @@ -0,0 +1,15 @@ +namespace StarterNG.Classes; + +/// +/// 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 ). +/// +public sealed class AppState +{ + public static AppState Instance { get; } = new(); + + public Scenery? CurrentScenery { get; set; } + public Trainset? CurrentTrainset { get; set; } +} diff --git a/StarterNG/Classes/GameData.cs b/StarterNG/Classes/GameData.cs new file mode 100644 index 0000000..5813ecc --- /dev/null +++ b/StarterNG/Classes/GameData.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace StarterNG.Classes; + +public enum LoadPhase +{ + Vehicles, + Sceneries, + Done +} + +/// A single progress update emitted while the game data loads. +public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail); + +/// +/// 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. +/// +public sealed class GameData +{ + public static GameData Instance { get; } = new(); + + public VehicleDatabase Vehicles { get; } = new(); + public List Sceneries { get; } = new(); + + public bool Loaded { get; private set; } + + /// + /// Parses the vehicle database and all sceneries, reporting progress. + /// Runs synchronously; call it from a background thread. Never throws. + /// + public void Load(IProgress? progress = null, + string vehiclesDir = "databases/vehicles/", + string sceneryDir = "scenery/") + { + if (Loaded) + return; + + var vehicleFiles = VehicleDatabase.EnumerateFiles(vehiclesDir); + var scnFiles = EnumerateScenery(sceneryDir); + int total = Math.Max(1, vehicleFiles.Count + scnFiles.Count); + int done = 0; + + // Phase 1 - vehicle database + Vehicles.BeginLoad(); + foreach (string file in vehicleFiles) + { + progress?.Report(new LoadStatus((double)done / total, LoadPhase.Vehicles, + Path.GetFileName(file))); + try { Vehicles.LoadFile(file); } + catch { /* skip malformed file */ } + done++; + } + Vehicles.EndLoad(); + + // Phase 2 - sceneries + foreach (string file in scnFiles) + { + progress?.Report(new LoadStatus((double)done / total, LoadPhase.Sceneries, + Path.GetFileName(file))); + try { Sceneries.Add(new Scenery(file)); } + catch { /* skip unreadable scenery */ } + done++; + } + + progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null)); + Loaded = true; + } + + private static List EnumerateScenery(string sceneryDir) + { + if (!Directory.Exists(sceneryDir)) + return new List(); + + return Directory.GetFiles(sceneryDir, "*.scn") + .Where(p => !Path.GetFileName(p).StartsWith("$")) + .ToList(); + } +} diff --git a/StarterNG/Classes/Scenery.cs b/StarterNG/Classes/Scenery.cs index 7e30106..177bd8a 100644 --- a/StarterNG/Classes/Scenery.cs +++ b/StarterNG/Classes/Scenery.cs @@ -11,6 +11,11 @@ public class Scenery public List Trainsets; public string Group; public string Path; + + // The file content with each trainset block replaced by a {{i}} placeholder, + // used to rebuild the .scn on export. + private readonly string _template; + public Scenery(string path) { this.Path = path; @@ -19,7 +24,7 @@ public class Scenery throw new FileNotFoundException(path); var encoding = Encoding.GetEncoding(1250); // Windows-1250 string content = File.ReadAllText(path, encoding); - + // property scanning var match = Regex.Match( content, @@ -27,9 +32,9 @@ public class Scenery RegexOptions.Multiline ); this.Group = match.Success ? match.Groups[1].Value : null; - - - // parsing trainsets + + + // parsing trainsets List trainsetEntries = new List(); Regex regex = new Regex( @"trainset\b[\s\S]*?\bendtrainset\b", @@ -41,18 +46,23 @@ public class Scenery trainsetEntries.Add(match.Value); return $"{{{{{idx++}}}}}"; }); + _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) - { - try - { - Trainsets.Add(new Trainset(trainsetEntry)); - } - catch - { - // skip malformed trainset entries so one bad scenery - // doesn't break loading the rest - } - } + Trainsets.Add(new Trainset(trainsetEntry)); + } + + /// + /// Rebuilds the full .scn content with the (possibly modified) trainsets + /// substituted back into their original positions. + /// + public string BuildExportContent() + { + string result = _template; + for (int i = 0; i < Trainsets.Count; i++) + result = result.Replace("{{" + i + "}}", Trainsets[i].ToSceneryEntry()); + return result; } } \ No newline at end of file diff --git a/StarterNG/Classes/Settings.cs b/StarterNG/Classes/Settings.cs new file mode 100644 index 0000000..38cb906 --- /dev/null +++ b/StarterNG/Classes/Settings.cs @@ -0,0 +1,51 @@ +namespace StarterNG.Classes; + +/// +/// Application settings backing the Settings view. Load/Save are stubs for now; +/// the real persistence will be implemented later. +/// +public sealed class Settings +{ + public static Settings Instance { get; } = new(); + + // General + public string Language = "English"; + public bool Fullscreen; + public bool PauseWhenInactive; + public bool PauseOnStart; + public int CursorSensitivity = 3; + public bool InvertMouseHorizontal; + public bool InvertMouseVertical; + + // Other + public string ExecutablePath = "eu07.exe"; // game executable used to launch + public bool SelectExeAutomatically = true; + public bool DebugMode; + public bool VirtualShunting; + + // Graphics (subset) + public string Resolution = "1280x720"; + public bool VSync = true; + public bool RenderShadows = true; + + // Sound + public bool SoundEnabled = true; + public int Volume = 100; + + // Starter + public bool AutoCloseStarter; + public bool LargeThumbnails; + public bool AutoExpandSceneryTree; + + /// Loads settings from disk. TODO: implement persistence. + public void Load() + { + // intentionally empty for now + } + + /// Persists settings to disk. TODO: implement persistence. + public void Save() + { + // intentionally empty for now + } +} diff --git a/StarterNG/Classes/Trainset.cs b/StarterNG/Classes/Trainset.cs index 50b51d7..4d44275 100644 --- a/StarterNG/Classes/Trainset.cs +++ b/StarterNG/Classes/Trainset.cs @@ -22,11 +22,31 @@ public class Trainset public float Velocity; public string Description; public List Vehicles; + + /// Original .scn text of this trainset block (incl. endtrainset). + public string RawEntry; + + /// True if the block parsed cleanly; false blocks are exported verbatim. + public bool Parsed; + public Trainset(string trainsetEntry) { - + RawEntry = trainsetEntry; Vehicles = new List(); Description = ""; + try + { + ParseEntry(trainsetEntry); + Parsed = true; + } + catch + { + Parsed = false; + } + } + + private void ParseEntry(string trainsetEntry) + { List tokens = Regex .Matches(trainsetEntry, @"/\*[\s\S]*?\*/|//[^\r\n]*|[^\s\r\n]+") .Select(m => m.Value) @@ -113,6 +133,13 @@ public class Trainset } } + /// + /// The .scn text for this trainset for export. Unparsed blocks are written + /// back verbatim; parsed blocks are regenerated from the current vehicles. + /// + public string ToSceneryEntry() => + Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry; + public string GetTrainsetEntry() { string entry = ""; @@ -120,8 +147,8 @@ public class Trainset entry += "trainset "; entry += this.Name + " "; entry += this.Track + " "; - entry += this.Offset + " "; - entry += this.Velocity + " "; + entry += this.Offset.ToString(CultureInfo.InvariantCulture) + " "; + entry += this.Velocity.ToString(CultureInfo.InvariantCulture) + " "; entry += "\n"; foreach (Dynamic vehicle in Vehicles) { diff --git a/StarterNG/Classes/VehicleDatabase.cs b/StarterNG/Classes/VehicleDatabase.cs index 8127464..5f3f16e 100644 --- a/StarterNG/Classes/VehicleDatabase.cs +++ b/StarterNG/Classes/VehicleDatabase.cs @@ -94,6 +94,12 @@ public class VehicleDatabase /// Texture-uuid -> automatic-consist set that contains it. public Dictionary SetByTextureUuid { get; } = new(); + /// Texture-uuid -> texture (includes wrecks, so set refs resolve). + public Dictionary TextureByUuid { get; } = new(); + + /// skinfile (without extension, lower-case) -> texture. + public Dictionary TextureBySkin { get; } = new(); + private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true, @@ -102,32 +108,55 @@ public class VehicleDatabase }; /// - /// Loads the database. When a merged vehicles.json is present it is used, - /// otherwise every per-vehicle *.json file in the directory is read. + /// 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. /// public void Load(string directory = "databases/vehicles/") + { + BeginLoad(); + foreach (string file in EnumerateFiles(directory)) + LoadFile(file); + EndLoad(); + } + + /// Resets the aggregate before an incremental load. + public void BeginLoad() { Textures.Clear(); GroupsById.Clear(); Sets.Clear(); SetByTextureUuid.Clear(); + TextureByUuid.Clear(); + TextureBySkin.Clear(); + } + /// Finalises an incremental load (builds lookup indexes). + public void EndLoad() => BuildSetIndex(); + + /// + /// Files that make up the database: the merged vehicles.json if present, + /// otherwise every per-vehicle *.json. Used to drive load progress. + /// + public static List EnumerateFiles(string directory = "databases/vehicles/") + { if (!Directory.Exists(directory)) - return; + return new List(); string merged = Path.Combine(directory, "vehicles.json"); if (File.Exists(merged)) - { - LoadMerged(merged); - } - else - { - foreach (string file in Directory.GetFiles(directory, "*.json")) - LoadEntryFile(file); - } + return new List { merged }; - BuildSetIndex(); + return Directory.GetFiles(directory, "*.json").ToList(); + } + + /// Ingests a single database file (merged or per-vehicle). + public void LoadFile(string file) + { + if (string.Equals(Path.GetFileName(file), "vehicles.json", StringComparison.OrdinalIgnoreCase)) + LoadMerged(file); + else + LoadEntryFile(file); } private void LoadMerged(string file) @@ -171,6 +200,12 @@ public class VehicleDatabase foreach (var texture in entry.Textures) { + // index every texture (wrecks too) so set / skin references resolve + if (!string.IsNullOrEmpty(texture.Uuid)) + TextureByUuid[texture.Uuid!] = texture; + if (!string.IsNullOrEmpty(texture.Skinfile)) + TextureBySkin.TryAdd(Path.GetFileNameWithoutExtension(texture.Skinfile).ToLowerInvariant(), texture); + // skip wrecks from the standard browser list if (texture.Wreck) continue; Textures.Add(texture); @@ -179,6 +214,25 @@ public class VehicleDatabase Sets.AddRange(entry.Sets); } + /// + /// If the texture belongs to an automatic-consist set, returns all of that + /// set's textures in their defined order; otherwise null. + /// + public List? ResolveSet(VehicleTexture texture) + { + if (string.IsNullOrEmpty(texture.Uuid)) return null; + if (!SetByTextureUuid.TryGetValue(texture.Uuid!, out var set) || set.TextureRefs is null) + return null; + + var cars = new List(); + foreach (string uuid in set.TextureRefs) + { + if (!string.IsNullOrEmpty(uuid) && TextureByUuid.TryGetValue(uuid, out var tex)) + cars.Add(tex); + } + return cars.Count > 0 ? cars : null; + } + private void BuildSetIndex() { foreach (var set in Sets) @@ -192,14 +246,36 @@ public class VehicleDatabase } } - /// Best miniature name for a texture (texture_mini -> mini_ref -> group mini). + /// + /// 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. + /// public string? ResolveMiniName(VehicleTexture texture) { - if (!string.IsNullOrEmpty(texture.TextureMini)) return texture.TextureMini; - if (!string.IsNullOrEmpty(texture.MiniRef)) return texture.MiniRef; - if (texture.Group is not null && GroupsById.TryGetValue(texture.Group, out var grp)) + if (!string.IsNullOrEmpty(texture.TextureMini) && MiniPath(texture.TextureMini) != null) + return texture.TextureMini; + + if (texture.Group != null && GroupsById.TryGetValue(texture.Group, out var grp) + && !string.IsNullOrEmpty(grp.Mini)) return grp.Mini; - return null; + + return texture.TextureMini; + } + + /// Resolved mini for a skin file (matched case-insensitively), or null. + public string? MiniForSkin(string? skinFile) + { + if (string.IsNullOrEmpty(skinFile)) return null; + string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant(); + return TextureBySkin.TryGetValue(key, out var tex) ? ResolveMiniName(tex) : null; + } + + /// The texture for a skin file (matched case-insensitively), or null. + public VehicleTexture? TextureForSkin(string? skinFile) + { + if (string.IsNullOrEmpty(skinFile)) return null; + string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant(); + return TextureBySkin.TryGetValue(key, out var tex) ? tex : null; } /// Header label for the group a texture belongs to. @@ -213,12 +289,30 @@ public class VehicleDatabase return groupId ?? ""; } - /// Resolves a miniature .bmp path under textures/mini/, or null if missing. + // Case-insensitive index of mini .bmp files (built once). + private static Dictionary? _miniIndex; + + /// + /// Resolves a miniature .bmp path under textures/mini/ case-insensitively, + /// or null if missing. The directory is indexed once and reused. + /// public static string? MiniPath(string? miniName, string miniDir = "textures/mini/") { if (string.IsNullOrEmpty(miniName)) return null; - string path = Path.Combine(miniDir, miniName + ".bmp"); - return File.Exists(path) ? path : null; + + var index = _miniIndex ??= BuildMiniIndex(miniDir); + return index.TryGetValue(miniName!.ToLowerInvariant(), out var path) ? path : null; + } + + private static Dictionary BuildMiniIndex(string miniDir) + { + var index = new Dictionary(); + if (!Directory.Exists(miniDir)) + return index; + + foreach (string file in Directory.GetFiles(miniDir, "*.bmp")) + index[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = file; + return index; } } diff --git a/StarterNG/SplashWindow.axaml b/StarterNG/SplashWindow.axaml new file mode 100644 index 0000000..0c7a4b3 --- /dev/null +++ b/StarterNG/SplashWindow.axaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + diff --git a/StarterNG/SplashWindow.axaml.cs b/StarterNG/SplashWindow.axaml.cs new file mode 100644 index 0000000..028444e --- /dev/null +++ b/StarterNG/SplashWindow.axaml.cs @@ -0,0 +1,30 @@ +using Avalonia.Controls; +using StarterNG.Classes; + +namespace StarterNG; + +public partial class SplashWindow : Window +{ + public SplashWindow() + { + InitializeComponent(); + } + + /// Updates the bar and status line. Must be called on the UI thread. + public void Report(LoadStatus status) + { + progressBar.Value = status.Fraction; + statusText.Text = Format(status); + } + + private static string Format(LoadStatus s) + { + string detail = string.IsNullOrEmpty(s.Detail) ? "" : $": {s.Detail}"; + return s.Phase switch + { + LoadPhase.Vehicles => App.Loc["LoadingVehicles"] + detail, + LoadPhase.Sceneries => App.Loc["LoadingSceneries"] + detail, + _ => App.Loc["LoadingDone"] + }; + } +} diff --git a/StarterNG/StarterNG.csproj b/StarterNG/StarterNG.csproj index e86cfd5..7541b4c 100644 --- a/StarterNG/StarterNG.csproj +++ b/StarterNG/StarterNG.csproj @@ -41,6 +41,8 @@ + + diff --git a/StarterNG/Views/Depot.axaml b/StarterNG/Views/Depot.axaml index 33c1533..2158b46 100644 --- a/StarterNG/Views/Depot.axaml +++ b/StarterNG/Views/Depot.axaml @@ -4,6 +4,7 @@ xmlns:sukiUi="clr-namespace:SukiUI.Controls;assembly=SukiUI" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:starterNg="clr-namespace:StarterNG" + xmlns:views="clr-namespace:StarterNG.Views" mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="700" x:Class="StarterNG.Views.Depot"> @@ -30,6 +31,8 @@ + @@ -47,6 +50,7 @@ diff --git a/StarterNG/Views/Depot.axaml.cs b/StarterNG/Views/Depot.axaml.cs index 35485e3..8306e45 100644 --- a/StarterNG/Views/Depot.axaml.cs +++ b/StarterNG/Views/Depot.axaml.cs @@ -8,47 +8,89 @@ using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Media.Imaging; +using Avalonia.Threading; using StarterNG.Classes; namespace StarterNG.Views; public partial class Depot : UserControl { - private readonly VehicleDatabase _db = new(); - private readonly List _sceneries = new(); + // Data is parsed once at startup (behind the splash) and shared here. + private readonly VehicleDatabase _db = GameData.Instance.Vehicles; + private readonly List _sceneries = GameData.Instance.Sceneries; - // The consist currently being edited (belongs to the selected scenery). - private readonly List _consist = new(); - private Dynamic? _selected; + // The consist being edited: a list of units (each unit = one or more cars). + private readonly List _consist = new(); + 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; + + // Shared, size-limited thumbnail cache (decoded down to display height). private readonly Dictionary _miniCache = new(); + private const int BrowserThumbHeight = 38; + private const int ConsistThumbHeight = 50; + private readonly Cursor _hand = new(StandardCursorType.Hand); private int _nameCounter; // suppresses combo SelectionChanged handlers while we populate them private bool _suppress; + // debounces search input so we don't rebuild on every keystroke + private DispatcherTimer? _searchTimer; + + // scenery-consist previews are built lazily on first dropdown open + private bool _consistPreviewsBuilt; + + // auto-expand search results only when there are few of them + private const int AutoExpandLimit = 40; + private const int MaxRowsPerGroup = 250; + // active database filters - private string[]? _categoryFilter; // legacy category letters, null = all - private string? _classFilter; // group mini, null = all + private Func? _categoryFilter; // matches a category letter, null = all + private string? _classFilter; // group mini (e.g. EU07), null = all // card highlight brushes (theme-neutral overlays) private static readonly IBrush CardBorder = new SolidColorBrush(Color.Parse("#33888888")); private static readonly IBrush CardBorderSel = new SolidColorBrush(Color.Parse("#AA4D8BFF")); private static readonly IBrush CardBgSel = new SolidColorBrush(Color.Parse("#224D8BFF")); + private static readonly IBrush Placeholder = new SolidColorBrush(Color.Parse("#22808080")); - // High-level category -> legacy category letters (from the JSON "category" field). - // NOTE: adjust these letters to match the actual database conventions. - private static readonly (string LocKey, string[] Letters)[] CategoryMap = + // Vehicle types, keyed by the JSON "category" letter. Powered/technical + // vehicles use lowercase letters; wagons use uppercase letters. + private static readonly (string LocKey, Func Match)[] CategoryDefs = { - ("CatElectric", new[] { "e" }), // electric locomotives - ("CatDiesel", new[] { "s" }), // diesel locomotives - ("CatEMU", new[] { "z" }), // electric multiple units (EN57, ...) - ("CatDMU", new[] { "d" }), // diesel multiple units - ("CatCarriagesA", new[] { "o" }), // passenger carriages - ("CatCarriagesB", new[] { "t" }), // freight carriages + ("CatElectricLoco", c => c == "e"), + ("CatDieselLoco", c => c == "s"), + ("CatSteamLoco", c => c == "p"), + ("CatEMU", c => c == "z"), + ("CatRailbus", c => c == "a"), + ("CatDraisine", c => c == "d"), + ("CatWork", c => c == "r"), + ("CatPrototype", c => c == "x"), + ("CatTram", c => c == "t"), + ("CatCar", c => c == "o"), + ("CatBus", c => c == "b"), + ("CatTruck", c => c == "c"), + ("CatPeople", c => c == "h"), + ("CatAnimals", c => c == "f"), + ("CatWagons", IsWagonCat), + ("CatOther", IsOtherCat), }; + // lowercase letters that already have a named type (so they aren't "other") + private static readonly HashSet NamedLowerCats = + new(StringComparer.Ordinal) { "a", "b", "c", "d", "e", "f", "h", "n", "o", "p", "r", "s", "t", "x", "z" }; + + private static bool IsWagonCat(string? c) => c is { Length: 1 } && c[0] >= 'A' && c[0] <= 'Z'; + private static bool IsOtherCat(string? 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" }; + // Placeholder coupling-bit labels (couplingData byte). Finished later. private static readonly string[] CouplingBits = { "Coupler", "Brake pipe", "Brake tank", "Control", "Gangway", "High voltage", "Heating", "Aux" }; @@ -60,40 +102,153 @@ public partial class Depot : UserControl if (Design.IsDesignMode) return; - LoadDatabase(); - LoadSceneries(); + _searchTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(220) }; + _searchTimer.Tick += (_, _) => + { + _searchTimer!.Stop(); + BuildBrowser(); + }; - PopulateCategoryCombo(); // -> populates class combo -> builds browser - PopulateSceneryCombo(); // -> populates consist combo for the first scenery + // Defer population so the tab renders immediately, then fills in. + Dispatcher.UIThread.Post(() => + { + PopulateCategoryCombo(); // -> populates class combo -> builds browser + PopulateSceneryCombo(); // -> populates consist combo for the first scenery + RebuildConsist(); + }, DispatcherPriority.Background); + + // When the tab is shown, pick up the consist selected in the scenery view. + AttachedToVisualTree += (_, _) => SyncFromSelection(); + } + + private void SyncFromSelection() + { + var trainset = AppState.Instance.CurrentTrainset; + if (trainset != null && !ReferenceEquals(trainset, _editingTrainset)) + LoadTrainset(trainset); + } + + // 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) + { + _editingTrainset = trainset; + _consist.Clear(); + + var cars = trainset.Vehicles.Select(v => + { + var c = v.Clone(); + c.MiniName = _db.MiniForSkin(c.SkinFile) ?? c.MiniName; // mini from texture_mini + return c; + }).ToList(); + + int i = 0; + while (i < cars.Count) + { + VehicleSet? set = null; + if (_db.TextureForSkin(cars[i].SkinFile)?.Uuid is { } uuid) + _db.SetByTextureUuid.TryGetValue(uuid, out set); + + if (set?.TextureRefs is { Count: > 1 }) + { + var members = new HashSet(set.TextureRefs.Where(r => !string.IsNullOrEmpty(r))); + var seen = new HashSet(); + var group = new List(); + + int j = i; + while (j < cars.Count) + { + if (_db.TextureForSkin(cars[j].SkinFile)?.Uuid is { } u + && members.Contains(u) && seen.Add(u)) + group.Add(cars[j++]); + else + break; + } + + if (group.Count >= 2) + { + _consist.Add(new ConsistItem + { + Cars = group, + Grouped = true, + Driver = group.Select(c => c.DriverType).FirstOrDefault(d => d != eDriverType.Nobody) + }); + i = j; + continue; + } + } + + // fallback: group consecutive cars sharing a unit number (e.g. EN57-001ra/s/rb) + if (IsUnitCar(cars[i])) + { + string key = UnitKey(cars[i]); + var group = new List { cars[i] }; + int j = i + 1; + while (j < cars.Count && IsUnitCar(cars[j]) && UnitKey(cars[j]) == key) + group.Add(cars[j++]); + + if (group.Count >= 2) + { + _consist.Add(new ConsistItem + { + Cars = group, + Grouped = true, + Driver = group.Select(c => c.DriverType).FirstOrDefault(d => d != eDriverType.Nobody) + }); + i = j; + continue; + } + } + + _consist.Add(new ConsistItem + { + Cars = new List { cars[i] }, + Grouped = false, + Driver = cars[i].DriverType + }); + i++; + } + + _selected = _consist.FirstOrDefault(); RebuildConsist(); } - // ----------------------------------------------------------------- loading + // ----------------------------------------------------- series / unit helpers - private void LoadDatabase() + // 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) { - try { _db.Load("databases/vehicles/"); } - catch { /* leave database empty on failure */ } - } - - private void LoadSceneries() - { - try + foreach (string suf in CarSuffixes) { - if (!Directory.Exists("scenery/")) - return; - - foreach (string scn in Directory.GetFiles("scenery/", "*.scn")) - { - if (Path.GetFileName(scn).StartsWith("$")) - continue; - try { _sceneries.Add(new Scenery(scn)); } - catch { /* skip unreadable scenery */ } - } + if (name.Length > suf.Length && + name.EndsWith(suf, StringComparison.OrdinalIgnoreCase) && + char.IsDigit(name[name.Length - suf.Length - 1])) + return name[..^suf.Length]; } - catch { /* no scenery folder */ } + return name; } + private static string Base(string skinOrName) => Path.GetFileNameWithoutExtension(skinOrName); + + 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); + + // Vehicle class = the "mini" of the group the texture belongs to (e.g. EU07). + private string ClassOf(VehicleTexture t) + { + if (t.Group != null && _db.GroupsById.TryGetValue(t.Group, out var g) && !string.IsNullOrEmpty(g.Mini)) + return g.Mini!; + return t.MiniRef ?? ""; + } + + // Vehicle type = the group's "category" letter. + private string? CategoryOf(VehicleTexture t) => + t.Group != null && _db.GroupsById.TryGetValue(t.Group, out var g) ? g.Category : null; + // ------------------------------------------------------------ category combo private void PopulateCategoryCombo() @@ -101,8 +256,8 @@ public partial class Depot : UserControl _suppress = true; categoryCombo.Items.Clear(); categoryCombo.Items.Add(new ComboBoxItem { Content = App.Loc["All"], Tag = null }); - foreach (var (locKey, letters) in CategoryMap) - categoryCombo.Items.Add(new ComboBoxItem { Content = App.Loc[locKey], Tag = letters }); + foreach (var (locKey, match) in CategoryDefs) + categoryCombo.Items.Add(new ComboBoxItem { Content = App.Loc[locKey], Tag = match }); categoryCombo.SelectedIndex = 0; _suppress = false; @@ -116,8 +271,8 @@ public partial class Depot : UserControl classCombo.Items.Clear(); classCombo.Items.Add(new ComboBoxItem { Content = App.Loc["All"], Tag = null }); - foreach (string mini in ClassesForCategory(_categoryFilter)) - classCombo.Items.Add(new ComboBoxItem { Content = mini, Tag = mini }); + foreach (string cls in ClassesForCategory(_categoryFilter)) + classCombo.Items.Add(new ComboBoxItem { Content = cls, Tag = cls }); classCombo.SelectedIndex = 0; _suppress = false; @@ -126,20 +281,18 @@ public partial class Depot : UserControl BuildBrowser(); } - private IEnumerable ClassesForCategory(string[]? letters) => - _db.GroupsById.Values - .Where(g => letters == null || - (g.Category != null && letters.Contains(g.Category))) - .Select(g => g.Mini) - .Where(m => !string.IsNullOrEmpty(m)) - .Select(m => m!) + private IEnumerable ClassesForCategory(Func? category) => + _db.Textures + .Where(t => category == null || category(CategoryOf(t))) + .Select(ClassOf) + .Where(s => !string.IsNullOrEmpty(s)) .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(m => m, StringComparer.OrdinalIgnoreCase); + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase); private void CategoryCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) { if (_suppress) return; - _categoryFilter = (categoryCombo.SelectedItem as ComboBoxItem)?.Tag as string[]; + _categoryFilter = (categoryCombo.SelectedItem as ComboBoxItem)?.Tag as Func; RebuildClassCombo(); } @@ -152,6 +305,8 @@ public partial class Depot : UserControl // ----------------------------------------------------------- vehicle browser + // Browser groups by the JSON "group" field, rendered as collapsed expanders. + // Collapsed groups hold no controls, so the list stays light and smooth. private void BuildBrowser() { browserStack.Children.Clear(); @@ -159,24 +314,33 @@ public partial class Depot : UserControl string search = searchBox.Text?.Trim() ?? ""; bool hasSearch = search.Length > 0; - var visible = _db.Textures.Where(t => PassesFilters(t, search, hasSearch)).ToList(); + var matched = _db.Textures.Where(t => PassesFilters(t, search, hasSearch)).ToList(); - var grouped = visible - .GroupBy(t => t.Group ?? "") - .OrderBy(g => _db.GroupHeader(g.Key), StringComparer.OrdinalIgnoreCase); + // Only auto-open groups when the search is specific. A broad search keeps + // groups folded (just headers + counts) so we never realize thousands of + // rows / decode thousands of bitmaps at once. + bool autoExpand = hasSearch && matched.Count <= AutoExpandLimit; + + // fold by class (the group's mini) + var grouped = matched + .GroupBy(ClassOf) + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase); foreach (var group in grouped) { - var list = group.ToList(); + var list = group + .OrderBy(t => t.TextureMini ?? t.Skinfile, StringComparer.OrdinalIgnoreCase) + .ToList(); + string header = string.IsNullOrEmpty(group.Key) ? App.Loc["Others"] - : _db.GroupHeader(group.Key); + : group.Key; var content = new StackPanel { Spacing = 2 }; var expander = new Expander { Header = $"{header} ({list.Count})", - IsExpanded = hasSearch || _classFilter != null, + IsExpanded = autoExpand, // folded by default HorizontalAlignment = HorizontalAlignment.Stretch }; expander.Content = content; @@ -186,8 +350,18 @@ public partial class Depot : UserControl { if (populated) return; populated = true; - foreach (var texture in list) - content.Children.Add(BuildBrowserItem(texture)); + + int shown = Math.Min(list.Count, MaxRowsPerGroup); + for (int i = 0; i < shown; i++) + content.Children.Add(BuildBrowserItem(list[i])); + + if (list.Count > shown) + content.Children.Add(new TextBlock + { + Text = $"… +{list.Count - shown}", + Opacity = 0.6, + Margin = new Thickness(6, 2) + }); } if (expander.IsExpanded) @@ -211,19 +385,12 @@ public partial class Depot : UserControl private bool PassesFilters(VehicleTexture t, string search, bool hasSearch) { - if (_categoryFilter != null) - { - string? cat = GroupCategory(t); - if (cat == null || !_categoryFilter.Contains(cat)) - return false; - } + if (_categoryFilter != null && !_categoryFilter(CategoryOf(t))) + return false; - if (_classFilter != null) - { - string? mini = GroupMini(t); - if (!string.Equals(mini, _classFilter, StringComparison.OrdinalIgnoreCase)) - return false; - } + if (_classFilter != null && + !string.Equals(ClassOf(t), _classFilter, StringComparison.OrdinalIgnoreCase)) + return false; if (hasSearch && !Matches(t, search)) return false; @@ -231,61 +398,155 @@ public partial class Depot : UserControl return true; } - private string? GroupCategory(VehicleTexture t) => - t.Group != null && _db.GroupsById.TryGetValue(t.Group, out var g) ? g.Category : null; - - private string? GroupMini(VehicleTexture t) => - t.Group != null && _db.GroupsById.TryGetValue(t.Group, out var g) ? g.Mini : null; - private bool Matches(VehicleTexture t, string f) { bool C(string? s) => !string.IsNullOrEmpty(s) && s.Contains(f, StringComparison.OrdinalIgnoreCase); return C(t.Skinfile) || C(t.Model) || C(t.TextureMini) || C(t.MiniRef) - || C(t.Meta?.Vehicle) || C(t.Meta?.Operator) || C(_db.GroupHeader(t.Group)); + || C(t.Meta?.Vehicle) || C(t.Meta?.Operator) || C(ClassOf(t)); } private Control BuildBrowserItem(VehicleTexture texture) { - var img = new Image { Height = 40, Width = 66, Stretch = Stretch.Uniform }; - var bmp = GetMiniBitmap(_db.ResolveMiniName(texture)); - if (bmp != null) img.Source = bmp; + // Grid with a star label column keeps the label width constrained so it + // wraps instead of overlapping the Add button. + var grid = new Grid + { + Margin = new Thickness(2, 1), + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto") + }; + ToolTip.SetTip(grid, texture.FullPath); + var thumb = CreateLazyMini(_db.ResolveMiniName(texture), BrowserThumbHeight, 64); + thumb.VerticalAlignment = VerticalAlignment.Center; + Grid.SetColumn(thumb, 0); + + var set = _db.ResolveSet(texture); var label = new TextBlock { - Text = BrowserLabel(texture), + Text = BrowserLabel(texture, set), VerticalAlignment = VerticalAlignment.Center, FontSize = 12, - TextWrapping = TextWrapping.Wrap + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(8, 0, 8, 0) }; + Grid.SetColumn(label, 1); - var stack = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; - stack.Children.Add(img); - stack.Children.Add(label); - - var button = new Button + var add = new Button { - Content = stack, - HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Left, + Content = App.Loc["AddVehicle"], + FontSize = 11, + Padding = new Thickness(10, 3), + VerticalAlignment = VerticalAlignment.Center, Cursor = _hand }; - button.Classes.Add("Basic"); - button.Click += (_, _) => AddFromTexture(texture); - ToolTip.SetTip(button, texture.FullPath); - return button; + add.Classes.Add("Flat"); + ToolTip.SetTip(add, set != null ? App.Loc["TipAddUnit"] : App.Loc["TipAddVehicle"]); + add.Click += (_, _) => AddTexture(texture); + Grid.SetColumn(add, 2); + + grid.Children.Add(thumb); + grid.Children.Add(label); + grid.Children.Add(add); + + // double-click the row to replace the selected consist vehicle + grid.DoubleTapped += (_, _) => ReplaceSelected(texture); + return grid; } - private string BrowserLabel(VehicleTexture texture) + // 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) + { + if (_selected is null) + { + AddTexture(texture); + return; + } + int i = _consist.IndexOf(_selected); + if (i < 0) + { + AddTexture(texture); + return; + } + + var set = _db.ResolveSet(texture); + var unit = set ?? new List { texture }; + var item = new ConsistItem + { + Cars = unit.Select(MakeDynamic).ToList(), + Grouped = unit.Count > 1, + Driver = _selected.Driver, + Flipped = _selected.Flipped + }; + _consist[i] = item; + _selected = item; + RebuildConsist(); + } + + // An image that decodes its mini only while it is on screen, and releases it + // when scrolled out of view, so an open group never holds every bitmap at once. + private Image CreateLazyMini(string? miniName, int height, double width) + { + var img = new Image { Height = height, Width = width, Stretch = Stretch.Uniform }; + bool loaded = false; + img.EffectiveViewportChanged += (_, e) => + { + bool visible = e.EffectiveViewport.Width > 0 && e.EffectiveViewport.Height > 0; + if (visible && !loaded) + { + img.Source = GetMiniBitmap(miniName, height); + loaded = true; + } + else if (!visible && loaded) + { + img.Source = null; + loaded = false; + } + }; + return img; + } + + private string BrowserLabel(VehicleTexture texture, IReadOnlyList? set) { string name = !string.IsNullOrEmpty(texture.TextureMini) ? texture.TextureMini! - : Path.GetFileNameWithoutExtension(texture.Skinfile); + : Base(texture.Skinfile); if (!string.IsNullOrEmpty(texture.Meta?.Operator)) name += $" · {texture.Meta!.Operator}"; + if (set is { Count: > 1 }) + name += $" [{set.Count}]"; return name; } + // Clicking "Add" appends to the consist. If the texture belongs to an + // auto-consist set, the whole set is added as one locked unit; otherwise the + // single vehicle is added. + private void AddTexture(VehicleTexture texture) + { + var set = _db.ResolveSet(texture); + var unit = set ?? new List { texture }; + + var item = new ConsistItem + { + Cars = unit.Select(MakeDynamic).ToList(), + Grouped = unit.Count > 1, + Driver = eDriverType.Nobody, + Flipped = false + }; + + // insert after the selected vehicle, or append when nothing is selected + int at = _consist.Count; + if (_selected != null) + { + int si = _consist.IndexOf(_selected); + if (si >= 0) at = si + 1; + } + _consist.Insert(at, item); + _selected = item; + RebuildConsist(); + } + // ------------------------------------------------------------- scenery combos private void PopulateSceneryCombo() @@ -321,18 +582,38 @@ public partial class Depot : UserControl if (trainset.Vehicles.All(v => string.IsNullOrWhiteSpace(v.Name))) continue; + // lightweight text content now; the rich mini-preview is built + // lazily the first time the dropdown is opened sceneryConsistCombo.Items.Add(new ComboBoxItem { - Content = BuildConsistPreview(trainset), + Content = ConsistSummary(trainset), Tag = trainset }); } } + _consistPreviewsBuilt = false; sceneryConsistCombo.SelectedItem = null; _suppress = false; } + private static string ConsistSummary(Trainset trainset) + { + string text = string.Join(" + ", trainset.Vehicles.Select(v => v.SkinFile)); + return text.Length > 70 ? text[..67] + "..." : text; + } + + // Builds the mini-thumbnail previews only once, on first dropdown open. + private void SceneryConsistCombo_OnDropDownOpened(object? sender, EventArgs e) + { + if (_consistPreviewsBuilt) return; + _consistPreviewsBuilt = true; + + foreach (var obj in sceneryConsistCombo.Items) + if (obj is ComboBoxItem { Tag: Trainset trainset } item) + item.Content = BuildConsistPreview(trainset); + } + // Combo item: row of mini thumbnails on top, skin names underneath. private Control BuildConsistPreview(Trainset trainset) { @@ -341,15 +622,13 @@ public partial class Depot : UserControl foreach (var v in trainset.Vehicles) { - var bmp = GetMiniBitmap(v.MiniName) ?? GetMiniBitmap(v.SkinFile); + var bmp = GetMiniBitmap(_db.MiniForSkin(v.SkinFile) ?? v.MiniName, 28); if (bmp != null) - minis.Children.Add(new Image { Source = bmp, Height = 26, Stretch = Stretch.Uniform }); + minis.Children.Add(new Image { Source = bmp, Height = 28, Stretch = Stretch.Uniform }); else minis.Children.Add(new Border { - Height = 26, Width = 44, - Background = new SolidColorBrush(Color.Parse("#22808080")), - CornerRadius = new CornerRadius(3) + Height = 28, Width = 46, Background = Placeholder, CornerRadius = new CornerRadius(3) }); names.Children.Add(new TextBlock { Text = v.SkinFile, FontSize = 10, Opacity = 0.8 }); @@ -368,25 +647,24 @@ public partial class Depot : UserControl PopulateConsistCombo(scenery); } + // Selecting a scenery consist binds the editor to that trainset. private void SceneryConsistCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) { if (_suppress) return; if (sceneryConsistCombo.SelectedItem is not ComboBoxItem { Tag: Trainset trainset }) return; - _consist.Clear(); - foreach (var vehicle in trainset.Vehicles) - _consist.Add(vehicle.Clone()); - _selected = _consist.FirstOrDefault(); - RebuildConsist(); + AppState.Instance.CurrentScenery = (sceneryCombo.SelectedItem as ComboBoxItem)?.Tag as Scenery; + AppState.Instance.CurrentTrainset = trainset; + LoadTrainset(trainset); } // ------------------------------------------------------------ consist edits - private void AddFromTexture(VehicleTexture texture) + private Dynamic MakeDynamic(VehicleTexture texture) { - string skin = Path.GetFileNameWithoutExtension(texture.Skinfile); - var dyn = new Dynamic + string skin = Base(texture.Skinfile); + return new Dynamic { RangeMax = -1, RangeMin = -1, @@ -400,9 +678,6 @@ public partial class Depot : UserControl Tail = "", MiniName = _db.ResolveMiniName(texture) }; - _consist.Add(dyn); - _selected = dyn; - RebuildConsist(); } private string MakeUniqueName(string baseName) @@ -421,9 +696,9 @@ public partial class Depot : UserControl return d; } - private void MoveLeft(Dynamic d) + private void MoveLeft(ConsistItem item) { - int i = _consist.IndexOf(d); + int i = _consist.IndexOf(item); if (i > 0) { (_consist[i - 1], _consist[i]) = (_consist[i], _consist[i - 1]); @@ -431,9 +706,9 @@ public partial class Depot : UserControl } } - private void MoveRight(Dynamic d) + private void MoveRight(ConsistItem item) { - int i = _consist.IndexOf(d); + int i = _consist.IndexOf(item); if (i >= 0 && i < _consist.Count - 1) { (_consist[i + 1], _consist[i]) = (_consist[i], _consist[i + 1]); @@ -441,22 +716,22 @@ public partial class Depot : UserControl } } - private void RemoveVehicle(Dynamic d) + private void RemoveItem(ConsistItem item) { - _consist.Remove(d); - if (ReferenceEquals(_selected, d)) _selected = null; + _consist.Remove(item); + if (ReferenceEquals(_selected, item)) _selected = null; RebuildConsist(); } - private void FlipVehicle(Dynamic d) + private void FlipItem(ConsistItem item) { - d.Flipped = !d.Flipped; + item.Flipped = !item.Flipped; RebuildConsist(); } - private void CycleDriver(Dynamic d) + private void CycleDriver(ConsistItem item) { - d.DriverType = d.DriverType switch + item.Driver = item.Driver switch { eDriverType.Nobody => eDriverType.Headdriver, eDriverType.Headdriver => eDriverType.Reardriver, @@ -466,66 +741,117 @@ public partial class Depot : UserControl RebuildConsist(); } + // Breaks a grouped unit into individual single-car items. + private void SplitItem(ConsistItem item) + { + int i = _consist.IndexOf(item); + if (i < 0) return; + + _consist.RemoveAt(i); + for (int c = 0; c < item.Cars.Count; c++) + { + _consist.Insert(i + c, new ConsistItem + { + Cars = new List { item.Cars[c] }, + Grouped = false, + Flipped = item.Flipped, + Driver = c == 0 ? item.Driver : eDriverType.Nobody + }); + } + _selected = _consist[i]; + RebuildConsist(); + } + // ----------------------------------------------------------- consist render private void RebuildConsist() { + // The first vehicle drives from cab A by default (unless a crew is set). + if (_consist.Count > 0 && _consist.All(i => i.Driver == eDriverType.Nobody)) + _consist[0].Driver = eDriverType.Headdriver; + consistStack.Children.Clear(); for (int i = 0; i < _consist.Count; i++) { - var d = _consist[i]; - consistStack.Children.Add(BuildCard(d)); + var item = _consist[i]; + consistStack.Children.Add(BuildCard(item)); if (i < _consist.Count - 1) - consistStack.Children.Add(BuildCoupler(d)); + consistStack.Children.Add(BuildCoupler(item)); } emptyHint.IsVisible = _consist.Count == 0; UpdateSummary(); UpdateDetails(); + WriteBackToScenery(); } - private Control BuildCard(Dynamic d) + // 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() { - bool selected = ReferenceEquals(_selected, d); + if (_editingTrainset is null) + return; - Control visual; - var bmp = GetMiniBitmap(d.MiniName) ?? GetMiniBitmap(d.SkinFile); - if (bmp != null) + var flat = new List(); + foreach (var item in _consist) { - var img = new Image { Height = 48, Stretch = Stretch.Uniform }; - img.Source = bmp; - if (d.Flipped) + var cars = item.Flipped ? item.Cars.AsEnumerable().Reverse().ToList() : item.Cars; + for (int k = 0; k < cars.Count; k++) { - img.RenderTransform = new ScaleTransform(-1, 1); - img.RenderTransformOrigin = RelativePoint.Center; + cars[k].DriverType = k == 0 ? item.Driver : eDriverType.Nobody; + flat.Add(cars[k]); } - visual = img; } - else + _editingTrainset.Vehicles = flat; + } + + private Control BuildCard(ConsistItem item) + { + bool selected = ReferenceEquals(_selected, item); + + var minis = new StackPanel { - visual = new Border + Orientation = Orientation.Horizontal, + Spacing = 1, + HorizontalAlignment = HorizontalAlignment.Center + }; + var cars = item.Flipped ? item.Cars.AsEnumerable().Reverse() : item.Cars; + foreach (var car in cars) + { + var bmp = GetMiniBitmap(car.MiniName, ConsistThumbHeight) ?? GetMiniBitmap(car.SkinFile, ConsistThumbHeight); + if (bmp != null) { - Height = 48, - Width = 96, - Background = new SolidColorBrush(Color.Parse("#22808080")), - CornerRadius = new CornerRadius(4), - Child = new TextBlock + var img = new Image { Source = bmp, Height = ConsistThumbHeight, Stretch = Stretch.Uniform }; + if (item.Flipped) { - Text = d.SkinFile, - FontSize = 10, - TextWrapping = TextWrapping.Wrap, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - TextAlignment = TextAlignment.Center + img.RenderTransform = new ScaleTransform(-1, 1); + img.RenderTransformOrigin = RelativePoint.Center; } - }; + minis.Children.Add(img); + } + else + { + minis.Children.Add(new Border + { + Height = ConsistThumbHeight, Width = 90, Background = Placeholder, + CornerRadius = new CornerRadius(4), + Child = new TextBlock + { + Text = car.SkinFile, FontSize = 10, TextWrapping = TextWrapping.Wrap, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + TextAlignment = TextAlignment.Center + } + }); + } } var nameBlock = new TextBlock { - Text = d.Name, + Text = item.Grouped ? UnitLabel(item) : item.Cars[0].Name, FontSize = 11, - MaxWidth = 110, + MaxWidth = Math.Max(120, item.Cars.Count * 96), TextWrapping = TextWrapping.Wrap, TextAlignment = TextAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center @@ -537,17 +863,33 @@ public partial class Depot : UserControl Spacing = 2, HorizontalAlignment = HorizontalAlignment.Center }; - controls.Children.Add(SmallButton("◀", App.Loc["TipMoveLeft"], () => MoveLeft(d))); - controls.Children.Add(DriverButton(d)); - controls.Children.Add(SmallButton("⇄", App.Loc["TipFlip"], () => FlipVehicle(d))); - controls.Children.Add(SmallButton("✕", App.Loc["TipRemove"], () => RemoveVehicle(d))); - controls.Children.Add(SmallButton("▶", App.Loc["TipMoveRight"], () => MoveRight(d))); + controls.Children.Add(SmallButton("◀", App.Loc["TipMoveLeft"], () => MoveLeft(item))); + controls.Children.Add(DriverButton(item)); + controls.Children.Add(SmallButton("⇄", App.Loc["TipFlip"], () => FlipItem(item))); + controls.Children.Add(SmallButton("✕", App.Loc["TipRemove"], () => RemoveItem(item))); + controls.Children.Add(SmallButton("▶", App.Loc["TipMoveRight"], () => MoveRight(item))); - var inner = new StackPanel { Spacing = 4, Width = 122 }; - inner.Children.Add(visual); + var inner = new StackPanel { Spacing = 4 }; + inner.Children.Add(minis); inner.Children.Add(nameBlock); inner.Children.Add(controls); + if (item.Grouped && item.Cars.Count > 1) + { + var split = new Button + { + Content = App.Loc["Split"], + FontSize = 11, + Padding = new Thickness(6, 2), + HorizontalAlignment = HorizontalAlignment.Center, + Cursor = _hand + }; + split.Classes.Add("Basic"); + ToolTip.SetTip(split, App.Loc["TipSplit"]); + split.Click += (_, _) => SplitItem(item); + inner.Children.Add(split); + } + var card = new Border { Margin = new Thickness(2, 0), @@ -561,15 +903,21 @@ public partial class Depot : UserControl }; card.PointerPressed += (_, _) => { - _selected = d; + _selected = item; RebuildConsist(); }; return card; } - // Coupling indicator between two vehicles. Hovering shows the 8-bit coupling - // editor (placeholder for now; full editing comes later). - private Control BuildCoupler(Dynamic d) + private static string UnitLabel(ConsistItem item) + { + string key = UnitKey(item.Cars[0]); + return item.Cars.Count > 1 ? $"{key} [{item.Cars.Count}]" : key; + } + + // Coupling indicator between two units. Hovering shows the 8-bit coupling + // editor for the left unit's last car (placeholder; full editing comes later). + private Control BuildCoupler(ConsistItem item) { var glyph = new TextBlock { @@ -588,7 +936,7 @@ public partial class Depot : UserControl VerticalAlignment = VerticalAlignment.Center, Child = glyph }; - ToolTip.SetTip(coupler, BuildCouplingBox(d)); + ToolTip.SetTip(coupler, BuildCouplingBox(item.Cars[^1])); ToolTip.SetShowDelay(coupler, 150); return coupler; } @@ -633,9 +981,9 @@ public partial class Depot : UserControl return btn; } - private Button DriverButton(Dynamic d) + private Button DriverButton(ConsistItem item) { - (string glyph, string color) = d.DriverType switch + (string glyph, string color) = item.Driver switch { eDriverType.Headdriver => ("1", "#4CAF50"), eDriverType.Reardriver => ("2", "#FF9800"), @@ -643,7 +991,7 @@ public partial class Depot : UserControl _ => ("–", "#888888") }; - var btn = SmallButton(glyph, App.Loc["TipDriver"], () => CycleDriver(d)); + var btn = SmallButton(glyph, App.Loc["TipDriver"], () => CycleDriver(item)); btn.Foreground = new SolidColorBrush(Color.Parse(color)); btn.FontWeight = FontWeight.Bold; return btn; @@ -651,11 +999,12 @@ public partial class Depot : UserControl private void UpdateSummary() { - int crewed = _consist.Count(v => v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver); - summaryLabel.Text = $"{_consist.Count} {App.Loc["Vehicles"]} · {crewed} {App.Loc["Crew"]}"; + int cars = _consist.Sum(i => i.Cars.Count); + int crewed = _consist.Count(i => i.Driver is eDriverType.Headdriver or eDriverType.Reardriver); + summaryLabel.Text = $"{cars} {App.Loc["Vehicles"]} · {crewed} {App.Loc["Crew"]}"; } - // Brakes / Loads tabs are placeholders bound to the selected vehicle. + // Brakes / Loads tabs are placeholders bound to the selected unit. private void UpdateDetails() { if (_selected is null) @@ -665,33 +1014,43 @@ public partial class Depot : UserControl return; } - var d = _selected; + var lead = _selected.Cars[0]; + string title = _selected.Grouped ? UnitLabel(_selected) : lead.Name; brakesContent.Text = - $"{App.Loc["Vehicle"]}: {d.Name}\n" + - $"coupling: {d.couplingData}\n\n" + + $"{App.Loc["Vehicle"]}: {title}\n" + + $"cars: {string.Join(", ", _selected.Cars.Select(c => c.SkinFile))}\n\n" + $"{App.Loc["ComingSoon"]}"; loadsContent.Text = - $"{App.Loc["Vehicle"]}: {d.Name}\n\n" + + $"{App.Loc["Vehicle"]}: {title}\n\n" + $"{App.Loc["ComingSoon"]}"; } // ------------------------------------------------------------------- minis - private Bitmap? GetMiniBitmap(string? name) + // 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) { if (string.IsNullOrEmpty(name)) return null; - if (_miniCache.TryGetValue(name, out var cached)) + + string key = $"{name}@{height}"; + if (_miniCache.TryGetValue(key, out var cached)) return cached; - string? path = VehicleDatabase.MiniPath(name); Bitmap? bmp = null; + string? path = VehicleDatabase.MiniPath(name); if (path != null) { - try { bmp = new Bitmap(path); } + try + { + using var fs = File.OpenRead(path); + bmp = Bitmap.DecodeToHeight(fs, height); + } catch { bmp = null; } } - _miniCache[name] = bmp; + _miniCache[key] = bmp; return bmp; } @@ -699,15 +1058,30 @@ public partial class Depot : UserControl private void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e) { - if (_suppress) return; - BuildBrowser(); + if (_suppress || _searchTimer is null) return; + _searchTimer.Stop(); + _searchTimer.Start(); } private void NewConsist_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { + // detach from any scenery trainset - this consist is now standalone + _editingTrainset = null; + AppState.Instance.CurrentTrainset = null; _consist.Clear(); _selected = null; + _suppress = true; sceneryConsistCombo.SelectedItem = null; + _suppress = false; RebuildConsist(); } } + +// One entry in the consist: a single vehicle or a locked multi-car unit. +public sealed class ConsistItem +{ + public List Cars { get; set; } = new(); + public bool Grouped { get; set; } + public bool Flipped { get; set; } + public eDriverType Driver { get; set; } = eDriverType.Nobody; +} diff --git a/StarterNG/Views/Scenarios.axaml b/StarterNG/Views/Scenarios.axaml index 51e7759..0df469e 100644 --- a/StarterNG/Views/Scenarios.axaml +++ b/StarterNG/Views/Scenarios.axaml @@ -45,8 +45,9 @@ \ No newline at end of file diff --git a/StarterNG/Views/Scenarios.axaml.cs b/StarterNG/Views/Scenarios.axaml.cs index 4852c6f..910a806 100644 --- a/StarterNG/Views/Scenarios.axaml.cs +++ b/StarterNG/Views/Scenarios.axaml.cs @@ -1,8 +1,11 @@ using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; +using System.Text; using Avalonia; using Avalonia.Controls; +using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using Avalonia.Media.Imaging; using StarterNG.Classes; @@ -16,20 +19,10 @@ public partial class Scenarios : UserControl public Scenarios() { InitializeComponent(); - sceneries = new List(); - - // load sceneries - List scnFiles = Directory.GetFiles("scenery/", "*.scn").ToList(); - foreach (string scnFile in scnFiles) - { - // skip temp sceneries - if (Path.GetFileName(scnFile).StartsWith("$")) - continue; - - // Parse all files - sceneries.Add(new Scenery(scnFile)); - } - + + // Sceneries are parsed once at startup (behind the splash). + sceneries = GameData.Instance.Sceneries; + var groupNodes = new Dictionary(); for (int i = 0; i < sceneries.Count; i++) @@ -109,20 +102,23 @@ public partial class Scenarios : UserControl if (vItem?.Tag is not int vTag) return; Trainset selectedTrainset = selectedScn.Trainsets[vTag]; + + // share the selection so the depot edits this consist in place + AppState.Instance.CurrentScenery = selectedScn; + AppState.Instance.CurrentTrainset = selectedTrainset; + + var db = GameData.Instance.Vehicles; foreach (var train in selectedTrainset.Vehicles) { - string path = Path.Combine( - "textures", - "mini", - train.SkinFile + ".bmp" - ); - - if (!File.Exists(path)) + // thumbnail name comes from the matching texture's texture_mini + string miniName = db.MiniForSkin(train.SkinFile) ?? train.SkinFile; + string? path = VehicleDatabase.MiniPath(miniName); + if (path is null) { // fallback continue; - } - + } + var bitmap = new Bitmap(path); var image = new Image { @@ -135,4 +131,46 @@ public partial class Scenarios : UserControl } missionDescription.Text = selectedTrainset.Description; } + + // Exports the (possibly depot-modified) scenery to a $-prefixed copy and + // launches the game on it with the selected consist's driven vehicle. + private void StartButton_OnClick(object? sender, RoutedEventArgs e) + { + var scenery = AppState.Instance.CurrentScenery; + if (scenery is null) + return; + var trainset = AppState.Instance.CurrentTrainset; + + // write scenery/$.scn with the replaced trainsets + string dir = System.IO.Path.GetDirectoryName(scenery.Path) ?? "scenery"; + string exportName = "$" + System.IO.Path.GetFileName(scenery.Path); + string exportPath = System.IO.Path.Combine(dir, exportName); + try + { + File.WriteAllText(exportPath, scenery.BuildExportContent(), Encoding.GetEncoding(1250)); + } + catch + { + return; // couldn't write the scenery file + } + + // the player's vehicle is the node name of the driven car in the consist + string? vehicle = trainset?.Vehicles + .FirstOrDefault(v => v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver)?.Name + ?? trainset?.Vehicles.FirstOrDefault()?.Name; + + // launch the game: -s $.scn -v + try + { + Process.Start(new ProcessStartInfo(StarterNG.Classes.Settings.Instance.ExecutablePath) + { + Arguments = $"-s {exportName} -v {vehicle}", + UseShellExecute = true + }); + } + catch + { + // executable not found / not configured yet + } + } } \ No newline at end of file