From 43a361ff89271f4682575f7be72bfc4ab8b0de4e Mon Sep 17 00:00:00 2001 From: maj00r Date: Mon, 31 Aug 2026 00:44:48 +0200 Subject: [PATCH] split depot into domain and view classes --- StarterNG/Domain/Cargo.cs | 147 + StarterNG/Domain/Consist.cs | 582 +++ StarterNG/Domain/LoadWeights.cs | 73 + StarterNG/Domain/TexRandomizer.cs | 8 +- StarterNG/Domain/UData.cs | 6 +- StarterNG/Domain/VehicleInfo.cs | 30 + StarterNG/Views/Depot.axaml.cs | 3455 ++--------------- StarterNG/Views/Depot/ConsistDragging.cs | 396 ++ StarterNG/Views/Depot/CouplingBits.cs | 8 + StarterNG/Views/Depot/MiniTextures.cs | 42 + .../Views/Depot/TextureRandomizerDialog.cs | 166 + StarterNG/Views/Depot/VehicleBrowser.cs | 575 +++ StarterNG/Views/Depot/VehicleCardStyle.cs | 15 + StarterNG/Views/Depot/VehicleCards.cs | 447 +++ StarterNG/Views/Depot/VehicleDetails.cs | 597 +++ StarterNG/Views/Depot/Warehouse.cs | 231 ++ 16 files changed, 3629 insertions(+), 3149 deletions(-) create mode 100644 StarterNG/Domain/Cargo.cs create mode 100644 StarterNG/Domain/Consist.cs create mode 100644 StarterNG/Domain/LoadWeights.cs create mode 100644 StarterNG/Domain/VehicleInfo.cs create mode 100644 StarterNG/Views/Depot/ConsistDragging.cs create mode 100644 StarterNG/Views/Depot/CouplingBits.cs create mode 100644 StarterNG/Views/Depot/MiniTextures.cs create mode 100644 StarterNG/Views/Depot/TextureRandomizerDialog.cs create mode 100644 StarterNG/Views/Depot/VehicleBrowser.cs create mode 100644 StarterNG/Views/Depot/VehicleCardStyle.cs create mode 100644 StarterNG/Views/Depot/VehicleCards.cs create mode 100644 StarterNG/Views/Depot/VehicleDetails.cs create mode 100644 StarterNG/Views/Depot/Warehouse.cs diff --git a/StarterNG/Domain/Cargo.cs b/StarterNG/Domain/Cargo.cs new file mode 100644 index 0000000..0b2c76a --- /dev/null +++ b/StarterNG/Domain/Cargo.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using StarterNG.Classes; + +namespace StarterNG.Domain; + +public sealed class Cargo +{ + private readonly VehicleInfo _info; + private readonly Random _rng; + + public Cargo(VehicleInfo info, Random rng) + { + _info = info; + _rng = rng; + } + + public bool IsLoadable(ConsistItem item) => IsLoadable(item.Cars[0]); + + public bool IsLoadable(Dynamic car) + { + if (_info.CategoryOf(car) is "e" or "s" or "p") + return false; + return !string.IsNullOrWhiteSpace(_info.PhysicsFor(car)?.LoadAccepted); + } + + public int MaxFor(Dynamic car, string? type = null) + { + if (!IsLoadable(car)) return 0; + + if (Dynamic.IsPantStateType(type ?? car.LoadType)) return Dynamic.PantStateMax; + + if (car.MaxLoad >= 0) return car.MaxLoad; + return _info.PhysicsFor(car)?.MaxLoad ?? 0; + } + + public int LimitFor(Dynamic car, string? type = null) + { + int max = MaxFor(car, type); + return max > 0 ? max : (IsLoadable(car) ? int.MaxValue : 0); + } + + public List AcceptedTypes(Dynamic car) + { + var p = _info.PhysicsFor(car); + if (!IsLoadable(car) || p == null) + return new List(); + + return p.LoadAccepted.Split(',', ';') + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public bool Accepts(Dynamic car, string? type) => + !string.IsNullOrEmpty(type) && + AcceptedTypes(car).Contains(type!, StringComparer.OrdinalIgnoreCase); + + public void FillToMax(Dynamic car) + { + int max = MaxFor(car); + if (max <= 0) return; + if (string.IsNullOrEmpty(car.LoadType)) + car.LoadType = AcceptedTypes(car).FirstOrDefault(); + if (string.IsNullOrEmpty(car.LoadType)) return; + car.LoadCount = max; + } + + public void CopyFromPrevious(IReadOnlyList consist, ConsistItem item) + { + int idx = IndexOf(consist, item); + if (idx <= 0) return; + + var source = consist[idx - 1].Cars[0]; + foreach (var c in item.Cars) + { + if (!Accepts(c, source.LoadType)) continue; + c.LoadType = source.LoadType; + c.LoadCount = Math.Min(source.LoadCount, LimitFor(c)); + } + } + + public void FillUnit(ConsistItem item) + { + foreach (var c in item.Cars) + FillToMax(c); + } + + public void CopyToFollowing(IReadOnlyList consist, ConsistItem item) + { + int idx = IndexOf(consist, item); + if (idx < 0) return; + + var lead = item.Cars[0]; + string? type = lead.LoadType; + if (string.IsNullOrEmpty(type)) return; + + foreach (var c in consist.Skip(idx + 1).SelectMany(u => u.Cars)) + { + if (!AcceptedTypes(c).Contains(type!, StringComparer.OrdinalIgnoreCase)) + continue; + c.LoadType = type; + c.LoadCount = Math.Min(lead.LoadCount, MaxFor(c)); + } + } + + public void RandomTypes(IReadOnlyList consist) + { + foreach (var c in consist.SelectMany(u => u.Cars)) + { + var types = AcceptedTypes(c); + if (types.Count == 0) continue; + c.LoadType = types[_rng.Next(types.Count)]; + } + } + + public void MaxAmounts(IReadOnlyList consist) + { + foreach (var c in consist.SelectMany(u => u.Cars)) + FillToMax(c); + } + + public void RandomAmounts(IReadOnlyList consist) + { + foreach (var c in consist.SelectMany(u => u.Cars)) + { + int max = MaxFor(c); + if (max <= 0) continue; + if (string.IsNullOrEmpty(c.LoadType)) + c.LoadType = AcceptedTypes(c).FirstOrDefault(); + if (string.IsNullOrEmpty(c.LoadType)) continue; + int min = Math.Max(1, max / 10); + c.LoadCount = _rng.Next(min, max + 1); + } + } + + private static int IndexOf(IReadOnlyList consist, ConsistItem item) + { + for (int i = 0; i < consist.Count; i++) + if (ReferenceEquals(consist[i], item)) + return i; + return -1; + } +} diff --git a/StarterNG/Domain/Consist.cs b/StarterNG/Domain/Consist.cs new file mode 100644 index 0000000..b133614 --- /dev/null +++ b/StarterNG/Domain/Consist.cs @@ -0,0 +1,582 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +using StarterNG.Classes; + +namespace StarterNG.Domain; + +public sealed class Consist : IReadOnlyList +{ + private static readonly string[] CarSuffixes = { "sa", "sb", "ra", "rb", "s" }; + + private readonly VehicleDatabase _db; + private readonly VehicleInfo _info; + private readonly List _items = new(); + private int _nameCounter; + + public Consist(VehicleDatabase db, VehicleInfo info) + { + _db = db; + _info = info; + } + + public event Action? Changed; + + public Trainset? EditingTrainset { get; set; } + + public ConsistItem? Selected { get; set; } + + public Dynamic? SelectedCar { get; set; } + + public int Count => _items.Count; + + public ConsistItem this[int index] + { + get => _items[index]; + set => _items[index] = value; + } + + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public int IndexOf(ConsistItem item) => _items.IndexOf(item); + + public void Raise() => Changed?.Invoke(); + + public void LoadFrom(Trainset trainset) + { + EditingTrainset = trainset; + _items.Clear(); + + var cars = trainset.Vehicles.Select(v => + { + var c = v.Clone(); + c.MiniName = _db.MiniForSkin(c.SkinFile) ?? c.MiniName; + return c; + }).ToList(); + + int i = 0; + while (i < cars.Count) + { + if (HoldsNext(cars[i])) + { + var locked = new List { cars[i] }; + int j = i; + while (j < cars.Count - 1 && HoldsNext(cars[j])) + locked.Add(cars[++j]); + + _items.Add(new ConsistItem + { + Cars = locked, + Grouped = true, + Flipped = locked[0].Offset < 0, + Driver = UnitDriver(locked) + }); + i = j + 1; + continue; + } + + 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 group = new List(); + + int j = i; + while (j < cars.Count && group.Count < set.TextureRefs.Count) + { + if (_db.TextureForSkin(cars[j].SkinFile)?.Uuid is { } u + && members.Contains(u)) + group.Add(cars[j++]); + else + break; + } + + if (group.Count >= 2) + { + _items.Add(new ConsistItem + { + Cars = group, + Grouped = true, + Flipped = group[0].Offset < 0, + Driver = UnitDriver(group) + }); + i = j; + continue; + } + } + + 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) + { + _items.Add(new ConsistItem + { + Cars = group, + Grouped = true, + Flipped = group[0].Offset < 0, + Driver = UnitDriver(group) + }); + i = j; + continue; + } + } + + _items.Add(new ConsistItem + { + Cars = new List { cars[i] }, + Grouped = false, + Flipped = cars[i].Offset < 0, + Driver = cars[i].DriverType + }); + i++; + } + + Selected = _items.FirstOrDefault(); + SyncStartingVehicle(); + Raise(); + } + + public List Flatten() + { + var flat = new List(); + foreach (var item in _items) + { + var cars = item.Flipped ? item.Cars.AsEnumerable().Reverse().ToList() : item.Cars; + foreach (var car in cars) + flat.Add(car); + } + return flat; + } + + public void SyncStartingVehicle() + { + if (Selected is not { Cars.Count: > 0 } item) + return; + + var driven = item.Cars.FirstOrDefault(c => + c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver); + AppState.Instance.StartingVehicleName = (driven ?? item.Cars[0]).Name; + } + + public Dynamic MakeDynamic(VehicleTexture texture, Scenery? scenery) + { + string skin = Base(texture.Skinfile); + return new Dynamic + { + RangeMax = -1, + RangeMin = 0, + Name = MakeUniqueName(skin, scenery), + DataFolder = StripDynamicPrefix(texture.Directory), + SkinFile = skin, + MmdFile = string.IsNullOrEmpty(texture.Model) ? skin : texture.Model!, + Offset = 0f, + DriverType = eDriverType.Nobody, + Coupling = new Coupling { Flags = Coupling.Mechanical | Coupling.BrakePipe }, + HasVelocity = true, + MiniName = _db.ResolveMiniName(texture) + }; + } + + private string MakeUniqueName(string baseName, Scenery? scenery) + { + if (string.IsNullOrWhiteSpace(baseName)) baseName = "vehicle"; + baseName = baseName.Replace(' ', '_'); + + var used = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in _items) + foreach (var c in item.Cars) + if (!string.IsNullOrEmpty(c.Name)) + used.Add(c.Name!); + + if (scenery != null) + { + foreach (string n in scenery.LooseVehicleNames) + used.Add(n); + foreach (var ts in scenery.Trainsets) + { + if (ReferenceEquals(ts, EditingTrainset)) continue; + foreach (var v in ts.Vehicles) + if (!string.IsNullOrEmpty(v.Name)) + used.Add(v.Name!); + } + } + + string candidate; + do + candidate = $"{baseName}_{++_nameCounter}"; + while (used.Contains(candidate)); + return candidate; + } + + private static string StripDynamicPrefix(string directory) + { + string d = directory.Replace('\\', '/').TrimEnd('/'); + const string prefix = "dynamic/"; + if (d.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + d = d[prefix.Length..]; + return d; + } + + public void Insert(int at, ConsistItem item) => + _items.Insert(Math.Clamp(at, 0, _items.Count), item); + + public void MoveLeft(ConsistItem item) + { + int i = _items.IndexOf(item); + if (i > 0) + { + (_items[i - 1], _items[i]) = (_items[i], _items[i - 1]); + Raise(); + } + } + + public void MoveRight(ConsistItem item) + { + int i = _items.IndexOf(item); + if (i >= 0 && i < _items.Count - 1) + { + (_items[i + 1], _items[i]) = (_items[i], _items[i + 1]); + Raise(); + } + } + + public void Move(int from, int to) + { + if (from < 0 || from >= _items.Count) return; + + var item = _items[from]; + _items.RemoveAt(from); + to = Math.Clamp(to, 0, _items.Count); + _items.Insert(to, item); + + Selected = item; + AutoConnectAll(); + Raise(); + } + + public void Remove(ConsistItem item) + { + int gap = _items.IndexOf(item); + _items.Remove(item); + + if (ReferenceEquals(Selected, item)) + Selected = _items.Count == 0 + ? null + : _items[Math.Clamp(gap, 0, _items.Count - 1)]; + + Raise(); + } + + public void Flip(ConsistItem item) + { + int index = _items.IndexOf(item); + if (index < 0) return; + + int first = index; + while (first > 0 && HoldsTail(_items[first - 1])) + first--; + + int last = index; + while (last + 1 < _items.Count && HoldsTail(_items[last])) + last++; + + for (int k = first; k <= last; k++) + { + var card = _items[k]; + card.Flipped = !card.Flipped; + foreach (var car in card.Cars) + car.Offset = car.Offset >= 0 ? -1f : 0f; + } + + if (last > first) + _items.Reverse(first, last - first + 1); + + AutoConnectAll(); + Raise(); + } + + public void CycleDriver(ConsistItem item) + { + var car = ActiveCar(item); + car.DriverType = car.DriverType switch + { + eDriverType.Nobody => eDriverType.Headdriver, + eDriverType.Headdriver => eDriverType.Reardriver, + eDriverType.Reardriver => eDriverType.Passenger, + _ => eDriverType.Nobody + }; + item.Driver = UnitDriver(item.Cars); + if (ReferenceEquals(Selected, item)) + SyncStartingVehicle(); + Raise(); + AppState.Instance.NotifyChanged(); + } + + public void Split(ConsistItem item) + { + int i = _items.IndexOf(item); + if (i < 0) return; + + var order = item.Flipped + ? Enumerable.Reverse(item.Cars).ToList() + : item.Cars; + + _items.RemoveAt(i); + for (int c = 0; c < order.Count; c++) + { + _items.Insert(i + c, new ConsistItem + { + Cars = new List { order[c] }, + Grouped = false, + Flipped = item.Flipped, + Driver = order[c].DriverType + }); + } + Selected = _items[i]; + Raise(); + } + + public void Join(ConsistItem item) + { + int index = _items.IndexOf(item); + if (index < 0) return; + + int first = index; + while (first > 0 && HoldsTail(_items[first - 1])) + first--; + + int last = index; + + bool inUnit = first < index || HoldsTail(_items[index]); + if (!inUnit && last + 1 < _items.Count) + { + var card = _items[last]; + if (card.Cars.Count > 0) + TailCar(card).Coupling.Set(Coupling.WorkshopLock, true); + last++; + } + + while (last + 1 < _items.Count && HoldsTail(_items[last])) + last++; + + if (last == first) return; + + var cars = new List(); + var driver = eDriverType.Nobody; + for (int k = first; k <= last; k++) + { + cars.AddRange(_items[k].Cars); + if (driver == eDriverType.Nobody) + driver = _items[k].Driver; + } + + bool flipped = _items[first].Flipped; + if (flipped) + cars.Reverse(); + + var joined = new ConsistItem + { + Cars = cars, + Grouped = true, + Flipped = flipped, + Driver = driver + }; + + _items.RemoveRange(first, last - first + 1); + _items.Insert(first, joined); + Selected = joined; + + AutoConnectAll(); + Raise(); + } + + public void RandomizeOrder(Random rng) + { + int start = Selected != null ? _items.IndexOf(Selected) : 0; + if (start < 0) start = 0; + + var indices = new List(); + for (int i = start; i < _items.Count; i++) + { + if (_items[i].Grouped) continue; + string? cat = _info.CategoryOf(_items[i].Cars[0]); + if (cat is { Length: 1 } && char.IsUpper(cat[0])) + indices.Add(i); + } + + if (indices.Count < 2) return; + + var items = indices.Select(i => _items[i]).ToList(); + for (int i = items.Count - 1; i > 0; i--) + { + int j = rng.Next(i + 1); + (items[i], items[j]) = (items[j], items[i]); + } + for (int k = 0; k < indices.Count; k++) + _items[indices[k]] = items[k]; + + AutoConnectAll(); + Raise(); + } + + public void RandomizeOrientation(Random rng) + { + int start = Selected != null ? _items.IndexOf(Selected) : 0; + if (start < 0) start = 0; + if (start >= _items.Count) return; + + for (int i = start; i < _items.Count; i++) + if (rng.Next(2) == 0) + _items[i].Flipped = !_items[i].Flipped; + + AutoConnectAll(); + Raise(); + } + + public void AutoConnectAll() + { + var flat = new List<(Dynamic car, bool flipped)>(); + foreach (var item in _items) + { + var cars = item.Flipped ? item.Cars.AsEnumerable().Reverse() : item.Cars; + foreach (var c in cars) + flat.Add((c, item.Flipped)); + } + + for (int i = 0; i < flat.Count - 1; i++) + { + var (left, lf) = flat[i]; + var (right, rf) = flat[i + 1]; + + var lp = _info.PhysicsFor(left); + var rp = _info.PhysicsFor(right); + + int leftMax = lp == null ? 3 : (lf ? lp.AllowedFlagA : lp.AllowedFlagB); + int rightMax = rp == null ? 3 : (rf ? rp.AllowedFlagB : rp.AllowedFlagA); + int common = leftMax & rightMax; + + string lct = lp == null ? "" : (lf ? lp.ControlTypeA : lp.ControlTypeB); + string rct = rp == null ? "" : (rf ? rp.ControlTypeB : rp.ControlTypeA); + if ((common & Coupling.ControlMU) != 0 && + !string.Equals(lct, rct, StringComparison.OrdinalIgnoreCase)) + common &= ~Coupling.ControlMU; + + left.Coupling.Flags = left.Coupling.Locked ? -common : common; + } + } + + public bool CanFormUnit(ConsistItem left, ConsistItem right) + { + if (left.Cars.Count == 0 || right.Cars.Count == 0) + return false; + + var tail = TailCar(left); + var head = HeadCar(right); + + var lp = _info.PhysicsFor(tail); + var rp = _info.PhysicsFor(head); + int leftMax = lp == null ? 3 : (left.Flipped ? lp.AllowedFlagA : lp.AllowedFlagB); + int rightMax = rp == null ? 3 : (right.Flipped ? rp.AllowedFlagB : rp.AllowedFlagA); + + return (leftMax & rightMax & Coupling.WorkshopLock) != 0 || SameSet(tail, head); + } + + public bool SameSet(Dynamic a, Dynamic b) + { + if (IsUnitCar(a) && IsUnitCar(b) && UnitKey(a) == UnitKey(b)) + return true; + + string? ua = _db.TextureForSkin(a.SkinFile)?.Uuid; + string? ub = _db.TextureForSkin(b.SkinFile)?.Uuid; + if (string.IsNullOrEmpty(ua) || string.IsNullOrEmpty(ub)) + return false; + + return _db.SetByTextureUuid.TryGetValue(ua!, out var sa) && + _db.SetByTextureUuid.TryGetValue(ub!, out var sb) && + ReferenceEquals(sa, sb) && sa.TextureRefs.Count > 1; + } + + public Dynamic ActiveCar(ConsistItem item) => + SelectedCar != null && item.Cars.Contains(SelectedCar) ? SelectedCar : item.Cars[0]; + + public static IEnumerable UnitTargets(ConsistItem item) => item.Cars; + + public IEnumerable MemberTargets(ConsistItem item) => + item.Cars.Count == 1 ? item.Cars : new[] { ActiveCar(item) }; + + public static Dynamic TailCar(ConsistItem item) => + item.Flipped ? item.Cars[0] : item.Cars[^1]; + + public static Dynamic HeadCar(ConsistItem item) => + item.Flipped ? item.Cars[^1] : item.Cars[0]; + + public static bool HoldsTail(ConsistItem item) => + item.Cars.Count > 0 && HoldsNext(TailCar(item)); + + public static bool HoldsNext(Dynamic car) => + car.Coupling.Has(Coupling.WorkshopLock) || car.Coupling.Locked; + + public static eDriverType UnitDriver(IReadOnlyList group) => + group.Select(c => c.DriverType) + .Where(d => d != eDriverType.Nobody) + .DefaultIfEmpty(eDriverType.Nobody) + .First(); + + public static string Base(string skinOrName) => System.IO.Path.GetFileNameWithoutExtension(skinOrName); + + public static string UnitKey(Dynamic d) => StripCarSuffix(Base(d.SkinFile)); + + public static bool IsUnitCar(Dynamic d) => UnitKey(d) != Base(d.SkinFile); + + private static string StripCarSuffix(string name) + { + foreach (string suf in CarSuffixes) + { + if (name.Length > suf.Length && + name.EndsWith(suf, StringComparison.OrdinalIgnoreCase) && + char.IsDigit(name[name.Length - suf.Length - 1])) + return name[..^suf.Length]; + } + return name; + } + + public static string UnitLabel(ConsistItem item) + { + string key = UnitKey(item.Cars[0]); + return item.Cars.Count > 1 ? $"{key} [{item.Cars.Count}]" : key; + } + + public static int GetWagonNumber(Dynamic car) + { + string name = car.Name ?? ""; + int hash = name.LastIndexOf('#'); + if (hash >= 0 && hash + 1 < name.Length && name[(hash + 1)..].All(char.IsDigit) + && int.TryParse(name[(hash + 1)..], out int n)) + return n; + return 0; + } + + public void SetWagonNumber(Dynamic car, int number) + { + string name = car.Name ?? ""; + int hash = name.LastIndexOf('#'); + string baseName = (hash >= 0 && hash + 1 < name.Length && name[(hash + 1)..].All(char.IsDigit)) + ? name[..hash] + : name; + car.Name = number > 0 ? $"{baseName}#{number}" : baseName; + Raise(); + } +} diff --git a/StarterNG/Domain/LoadWeights.cs b/StarterNG/Domain/LoadWeights.cs new file mode 100644 index 0000000..f888a33 --- /dev/null +++ b/StarterNG/Domain/LoadWeights.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +using StarterNG.Classes; + +namespace StarterNG.Domain; + +public static class LoadWeights +{ + private static Dictionary? _weights; + + public static int WeightOf(string? name) + { + Load(); + return !string.IsNullOrEmpty(name) && _weights!.TryGetValue(name!, out int w) ? w : 1000; + } + + public static string Describe(string name) => + string.Equals(name, Dynamic.PantState, StringComparison.OrdinalIgnoreCase) + ? App.Loc["LoadPantState"] + : name; + + private static void Load() + { + if (_weights != null) + return; + + var weights = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [Dynamic.PantState] = 0 + }; + try + { + string path = Path.Combine("data", "load_weights.txt"); + if (File.Exists(path)) + { + var body = new StringBuilder(); + foreach (var raw in File.ReadAllLines(path, Encoding.GetEncoding(1250))) + { + string line = raw; + int comment = line.IndexOfAny(new[] { '#', ';' }); + if (comment >= 0) line = line[..comment]; + int slashes = line.IndexOf("//", StringComparison.Ordinal); + if (slashes >= 0) line = line[..slashes]; + body.Append(line).Append(' '); + } + + var tokens = body.Replace(":", " : ").ToString() + .Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + + for (int i = 1; i + 1 < tokens.Length; i++) + { + if (tokens[i] != ":") continue; + string name = tokens[i - 1]; + if (name is "{" or "}") continue; + if (!int.TryParse(tokens[i + 1], NumberStyles.Integer, + CultureInfo.InvariantCulture, out int w)) + continue; + weights[name] = w; + } + } + } + catch (Exception ex) + { + Infrastructure.Diagnostics.Log("data/load_weights.txt", ex); + } + + _weights = weights; + } +} diff --git a/StarterNG/Domain/TexRandomizer.cs b/StarterNG/Domain/TexRandomizer.cs index 926e20d..4cdbbaf 100644 --- a/StarterNG/Domain/TexRandomizer.cs +++ b/StarterNG/Domain/TexRandomizer.cs @@ -53,13 +53,13 @@ public sealed class TexRandomizer _stock.Add(parts); } } - catch + catch (Exception ex) { - + StarterNG.Infrastructure.Diagnostics.Log("texture randomizer stock list", ex); } } - public int Apply(IList consist, Func makeCar) + public int Apply(Consist consist, Func makeCar) { int changed = 0; for (int i = 0; i < consist.Count; i++) @@ -110,7 +110,7 @@ public sealed class TexRandomizer return changed; } - private string? ResolveFilterMini(ConsistItem item, int index, IList consist, VehicleTexture? current) + private string? ResolveFilterMini(ConsistItem item, int index, Consist consist, VehicleTexture? current) { if (current != null) { diff --git a/StarterNG/Domain/UData.cs b/StarterNG/Domain/UData.cs index 7af9142..dc275cb 100644 --- a/StarterNG/Domain/UData.cs +++ b/StarterNG/Domain/UData.cs @@ -145,14 +145,14 @@ public static class UData var fiz = physicsFor(v); if (fiz == null) continue; - var loads = fiz.LoadAccepted + var cargo = fiz.LoadAccepted .Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(x => x.Trim()) .Where(x => x.Length > 0) .ToList(); - if (loads.Count > 0) - v.LoadType = loads[rng.Next(loads.Count)]; + if (cargo.Count > 0) + v.LoadType = cargo[rng.Next(cargo.Count)]; } } diff --git a/StarterNG/Domain/VehicleInfo.cs b/StarterNG/Domain/VehicleInfo.cs new file mode 100644 index 0000000..28a3aa0 --- /dev/null +++ b/StarterNG/Domain/VehicleInfo.cs @@ -0,0 +1,30 @@ +using StarterNG.Classes; + +namespace StarterNG.Domain; + +public sealed class VehicleInfo +{ + private readonly VehicleDatabase _db; + + public VehicleInfo(VehicleDatabase db) => _db = db; + + public static string ClassOf(VehicleTexture t) => t.ResolvedClass; + + public static string? CategoryOf(VehicleTexture t) => t.ResolvedCategory; + + public static bool IsPoweredCategory(string? c) => + c is "e" or "s" or "p" or "z" or "a"; + + public VehicleTexture? TextureFor(Dynamic car) => _db.TextureForSkin(car.SkinFile); + + public string? CategoryOf(Dynamic car) => + TextureFor(car) is { } t ? CategoryOf(t) : null; + + public Physics? PhysicsFor(Dynamic car) + { + string? dbModel = TextureFor(car)?.Model; + return Physics.For(car.DataFolder, dbModel) + ?? Physics.For(car.DataFolder, car.MmdFile) + ?? Physics.For(car.DataFolder, car.SkinFile); + } +} diff --git a/StarterNG/Views/Depot.axaml.cs b/StarterNG/Views/Depot.axaml.cs index f0bc133..92f45bd 100644 --- a/StarterNG/Views/Depot.axaml.cs +++ b/StarterNG/Views/Depot.axaml.cs @@ -26,167 +26,73 @@ public partial class Depot : UserControl private readonly VehicleDatabase _db = GameData.Instance.Vehicles; private readonly List _sceneries = GameData.Instance.Sceneries; - private readonly List _consist = new(); + private readonly VehicleInfo _info; - private Dynamic? _selectedCar; - private Dynamic? _pressedCar; - private readonly List<(Border Frame, ConsistItem Item, Dynamic Car)> _memberChrome = new(); - private readonly List<(Button Badge, ConsistItem Item)> _driverChrome = new(); - private ConsistItem? _selected; + private readonly Consist _consist; - private Trainset? _editingTrainset; + private readonly Cargo _cargo; - private readonly Infrastructure.DropGap _dropGap = new(); - private readonly Infrastructure.EdgeScroller _edgeScroller = new(); - private double _dragCardWidth = 64; + private Warehouse _warehouse = null!; - private readonly Infrastructure.DropIndexTracker _dropTracker = new(); + private VehicleDetails _details = null!; - private int _pressCardIndex = -1; - private Point _pressPoint; - private bool _cardDragging; - private double _grabOffset; - private Control? _carried; + private VehicleCards _cards = null!; - private readonly Dictionary _miniCache = new(); - private int ConsistThumbHeight => - StarterNG.Classes.Settings.Instance.LargeThumbnails ? 68 : 34; + private ConsistDragging _drag = null!; - private const int MiniPreviewHeight = 54; - - private const int CompactThumbHeight = 24; - - private readonly Cursor _hand = new(StandardCursorType.Hand); - private int _nameCounter; - - private static readonly Random _rng = new(); + private VehicleBrowser _browser = null!; private bool _suppress; - private bool _syncingCombos; - private DispatcherTimer? _searchTimer; + private readonly MiniTextures _minis = new(); + private readonly Cursor _hand = new(StandardCursorType.Hand); + private static readonly Random _rng = new(); private Scenery? _listScenery; - private Func? _categoryFilter; - private string? _classFilter; - - private VehicleTexture? _browserSelected; - - private VehicleTexture? _dragTexture; - private int _dragConsistIndex = -1; - private const string DragVehicleFormat = "starterng/vehicle"; - private const string DragConsistFormat = "starterng/consist-index"; - - private PointerPressedEventArgs? _pendingDragPress; - private Point _pendingDragOrigin; - private VehicleTexture? _pendingDragTexture; - private int _pendingDragConsistIndex = -1; - private ConsistItem? _pendingBrowserSync; - private bool _dragSessionActive; - private const double DragThreshold = 6; - - private const int MaxListRows = 2000; - - private static readonly IBrush CardBorder = new SolidColorBrush(Color.Parse("#33888888")); - private static readonly IBrush CardBorderSel = new SolidColorBrush(Color.Parse("#AA41C400")); - private static readonly IBrush CardBgSel = new SolidColorBrush(Color.Parse("#2241C400")); - private static readonly IBrush Placeholder = new SolidColorBrush(Color.Parse("#22808080")); - - private static readonly (string LocKey, Func Match)[] CategoryDefs = - { - ("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"), - ("CatWagonsA", c => c == "A"), - ("CatWagonsB", c => c == "B"), - ("CatWagonsC", c => c == "C"), - ("CatWagonsD", c => c == "D"), - ("CatWagonsE", c => c == "E"), - ("CatWagonsF", c => c == "F"), - ("CatWagonsG", c => c == "G"), - ("CatWagonsH", c => c == "H"), - ("CatWagonsI", c => c == "I"), - ("CatWagonsJ", c => c == "J"), - ("CatWagonsK", c => c == "K"), - ("CatWagonsL", c => c == "L"), - ("CatWagonsM", c => c == "M"), - ("CatWagonsN", c => c == "N"), - ("CatWagonsO", c => c == "O"), - ("CatWagonsP", c => c == "P"), - ("CatWagonsR", c => c == "R"), - ("CatWagonsS", c => c == "S"), - ("CatWagonsT", c => c == "T"), - ("CatWagonsU", c => c == "U"), - ("CatWagonsV", c => c == "V"), - ("CatWagonsW", c => c == "W"), - ("CatWagonsX", c => c == "X"), - ("CatWagonsY", c => c == "Y"), - ("CatWagonsZ", c => c == "Z"), - - ("CatOther", IsOtherCat), - }; - - 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 IsOtherCat(string? c) => - c is { Length: 1 } && char.IsLower(c[0]) && !NamedLowerCats.Contains(c); - - private static readonly string[] CarSuffixes = { "sa", "sb", "ra", "rb", "s" }; - - private static readonly string[] CouplingBitKeys = - { "CplMechanical", "CplBrake", "CplControl", "CplHighVoltage", - "CplGangway", "CplAuxPneumatic", "CplHeating", "CplWorkshop" }; - public Depot() { + _info = new VehicleInfo(_db); + _consist = new Consist(_db, _info); + _cargo = new Cargo(_info, _rng); + _consist.Changed += RebuildConsist; + InitializeComponent(); if (Design.IsDesignMode) return; - _searchTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(220) }; - _searchTimer.Tick += (_, _) => - { - _searchTimer!.Stop(); - BuildList(); - }; + _warehouse = new Warehouse(warehouseList, removeFromWarehouseButton, _hand, + () => _consist.EditingTrainset, ApplyConsist); + + _browser = new VehicleBrowser(categoryCombo, classCombo, vehicleListBox, searchBox, + hideArchivalCheck, miniPreview, addVehicleButton, _db, _minis, + () => TextureBaseButton_OnClick(null, null!)); + + _drag = new ConsistDragging(consistStack, consistScroll, consistOverlay, + consistDropTarget, vehicleListBox, miniPreviewPanel, + _consist, () => _browser.Selected, InsertTextureAt, _browser.SelectInBrowser); + + _cards = new VehicleCards(consistStack, _db, _consist, _cargo, _minis, _hand, + RebuildConsist, () => _details.Refresh(), _browser.SelectInBrowser, _drag.ArmCardDrag); + + _details = new VehicleDetails( + selectedVehiclePanel, generalPanel, consistTexturePanel, couplerPanel, + brakesPanel, loadsPanel, damagePanel, removeVehicleButton, + _db, _info, _consist, _cargo, _hand, + RebuildConsist, _cards.RefreshMemberChrome, _browser.ShowTextureInfo); Dispatcher.UIThread.Post(() => { hideArchivalCheck.IsChecked = StarterNG.Classes.Settings.Instance.HideArchivalVehicles; - InitClassComboTemplates(); - PopulateCategoryCombo(); + _browser.InitClassComboTemplates(); + _browser.PopulateCategoryCombo(); PopulateSceneryConsists(); - PopulateWarehouse(); + _warehouse.Refresh(); RebuildConsist(); }, DispatcherPriority.Background); - consistStack.PointerMoved += Strip_OnPointerMoved; - consistStack.PointerReleased += Strip_OnPointerReleased; - consistStack.PointerCaptureLost += (_, _) => EndCardDrag(); - - DragDrop.SetAllowDrop(consistDropTarget, true); - consistDropTarget.AddHandler(DragDrop.DragOverEvent, Consist_OnDragOver); - consistDropTarget.AddHandler(DragDrop.DropEvent, Consist_OnDrop); - consistDropTarget.AddHandler(DragDrop.DragLeaveEvent, (_, _) => CloseDropGap()); - - vehicleListBox.AddHandler(InputElement.PointerMovedEvent, PendingDrag_OnPointerMoved, handledEventsToo: true); - vehicleListBox.AddHandler(InputElement.PointerReleasedEvent, PendingDrag_OnPointerReleased, handledEventsToo: true); - miniPreviewPanel.AddHandler(InputElement.PointerMovedEvent, PendingDrag_OnPointerMoved, handledEventsToo: true); - miniPreviewPanel.AddHandler(InputElement.PointerReleasedEvent, PendingDrag_OnPointerReleased, handledEventsToo: true); + _drag.Attach(); AttachedToVisualTree += (_, _) => SyncFromSelection(); PropertyChanged += (_, e) => @@ -194,701 +100,127 @@ public partial class Depot : UserControl if (e.Property == IsVisibleProperty && IsVisible) { SyncFromSelection(); - PopulateWarehouse(); + _warehouse.Refresh(); } }; } + private void AddToWarehouseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => + _ = _warehouse.SaveCurrentAsync(); + + private void RemoveFromWarehouseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => + _ = _warehouse.RemoveSelectedAsync(); + + private void WarehouseList_OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e) => + _warehouse.ApplySelected(); + + private void CategoryCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) => + _browser.CategoryCombo_OnSelectionChanged(sender, e); + + private void ClassCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) => + _browser.ClassCombo_OnSelectionChanged(sender, e); + + private void Combo_OnPointerWheel(object? sender, PointerWheelEventArgs e) => + _browser.Combo_OnPointerWheel(sender, e); + + private void VehicleListBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) => + _browser.VehicleListBox_OnSelectionChanged(sender, e); + + private void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e) => + _browser.SearchBox_OnTextChanged(sender, e); + + private void HideArchivalCheck_OnChanged(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => + _browser.HideArchivalCheck_OnChanged(sender, e); + + private void MiniPreview_OnPointerPressed(object? sender, PointerPressedEventArgs e) => + _drag.MiniPreviewPressed(sender, e); + + private void VehicleListBox_OnPointerPressed(object? sender, PointerPressedEventArgs e) => + _drag.VehicleListPressed(sender, e); + + private Dynamic NewVehicle(VehicleTexture texture) => + _consist.MakeDynamic(texture, _listScenery ?? AppState.Instance.CurrentScenery); + private void SyncFromSelection() { - bool hideArchival = StarterNG.Classes.Settings.Instance.HideArchivalVehicles; - if (hideArchivalCheck.IsChecked != hideArchival) - { - _suppress = true; - hideArchivalCheck.IsChecked = hideArchival; - _suppress = false; - BuildList(); - } + _browser.SyncHideArchival(); PopulateSceneryConsists(); var trainset = AppState.Instance.CurrentTrainset; - if (trainset != null && !ReferenceEquals(trainset, _editingTrainset)) - LoadTrainset(trainset); - else if (_editingTrainset != null) + if (trainset != null && !ReferenceEquals(trainset, _consist.EditingTrainset)) + _consist.LoadFrom(trainset); + else if (_consist.EditingTrainset != null) RebuildConsist(); } - 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; - return c; - }).ToList(); - - int i = 0; - while (i < cars.Count) - { - if (HoldsNext(cars[i])) - { - var locked = new List { cars[i] }; - int j = i; - while (j < cars.Count - 1 && HoldsNext(cars[j])) - locked.Add(cars[++j]); - - _consist.Add(new ConsistItem - { - Cars = locked, - Grouped = true, - Flipped = locked[0].Offset < 0, - Driver = UnitDriver(locked) - }); - i = j + 1; - continue; - } - - 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 group = new List(); - - int j = i; - while (j < cars.Count && group.Count < set.TextureRefs.Count) - { - if (_db.TextureForSkin(cars[j].SkinFile)?.Uuid is { } u - && members.Contains(u)) - group.Add(cars[j++]); - else - break; - } - - if (group.Count >= 2) - { - _consist.Add(new ConsistItem - { - Cars = group, - Grouped = true, - Flipped = group[0].Offset < 0, - Driver = UnitDriver(group) - }); - i = j; - continue; - } - } - - 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, - Flipped = group[0].Offset < 0, - Driver = UnitDriver(group) - }); - i = j; - continue; - } - } - - _consist.Add(new ConsistItem - { - Cars = new List { cars[i] }, - Grouped = false, - Flipped = cars[i].Offset < 0, - Driver = cars[i].DriverType - }); - i++; - } - - _selected = _consist.FirstOrDefault(); - SyncStartingVehicle(); - RebuildConsist(); - } - - private static eDriverType UnitDriver(IReadOnlyList group) => - group.Select(c => c.DriverType) - .Where(d => d != eDriverType.Nobody) - .DefaultIfEmpty(eDriverType.Nobody) - .First(); - - private void SyncStartingVehicle() - { - if (_selected is not { Cars.Count: > 0 } item) - return; - - var driven = item.Cars.FirstOrDefault(c => - c.DriverType is eDriverType.Headdriver or eDriverType.Reardriver); - AppState.Instance.StartingVehicleName = (driven ?? item.Cars[0]).Name; - } - - private static string StripCarSuffix(string name) - { - foreach (string suf in CarSuffixes) - { - if (name.Length > suf.Length && - name.EndsWith(suf, StringComparison.OrdinalIgnoreCase) && - char.IsDigit(name[name.Length - suf.Length - 1])) - return name[..^suf.Length]; - } - return name; - } - - private static string Base(string skinOrName) => Path.GetFileNameWithoutExtension(skinOrName); - - private static string UnitKey(Dynamic d) => StripCarSuffix(Base(d.SkinFile)); - - private static bool IsUnitCar(Dynamic d) => UnitKey(d) != Base(d.SkinFile); - - private static string ClassOf(VehicleTexture t) => t.ResolvedClass; - - private static string? CategoryOf(VehicleTexture t) => t.ResolvedCategory; - - private void PopulateCategoryCombo() - { - int keep = categoryCombo.SelectedIndex; - - _suppress = true; - categoryCombo.Items.Clear(); - foreach (var (locKey, match) in CategoryDefs) - categoryCombo.Items.Add(new ComboBoxItem { Content = App.Loc[locKey], Tag = match }); - categoryCombo.SelectedIndex = keep >= 0 && keep < categoryCombo.Items.Count ? keep : -1; - _suppress = false; - - _categoryFilter = categoryCombo.SelectedItem is ComboBoxItem { Tag: Func f } - ? f : null; - RebuildClassCombo(); - } - - private void RebuildClassCombo() - { - _suppress = true; - FillClassCombo(); - classCombo.SelectedIndex = -1; - _suppress = false; - - _classFilter = null; - BuildList(); - } - - private void InitClassComboTemplates() - { - classCombo.ItemTemplate = new FuncDataTemplate( - (cls, _) => ClassComboContent(cls, large: true), false); - classCombo.SelectionBoxItemTemplate = new FuncDataTemplate( - (cls, _) => ClassComboContent(cls, large: false), false); - } - - private void FillClassCombo() - { - classCombo.Items.Clear(); - foreach (string cls in ClassesForCategory(_categoryFilter)) - classCombo.Items.Add(cls); - } - - private bool ApplyFilters(string? category, string? cls) - { - var catItem = categoryCombo.Items.OfType() - .FirstOrDefault(it => it.Tag is Func f && f(category)); - - if (catItem != null && ReferenceEquals(categoryCombo.SelectedItem, catItem) && - string.Equals(_classFilter, cls, StringComparison.OrdinalIgnoreCase)) - return false; - - _suppress = true; - try - { - if (catItem != null) - { - categoryCombo.SelectedItem = catItem; - _categoryFilter = catItem.Tag as Func; - } - FillClassCombo(); - classCombo.SelectedItem = classCombo.Items.OfType() - .FirstOrDefault(s => string.Equals(s, cls, StringComparison.OrdinalIgnoreCase)); - _classFilter = classCombo.SelectedItem as string; - } - finally { _suppress = false; } - return true; - } - - private string? CategoryOfClass(string cls) => - _db.Textures.FirstOrDefault(t => string.Equals(ClassOf(t), cls, StringComparison.OrdinalIgnoreCase)) - is { } t ? CategoryOf(t) : null; - - private Control ClassComboContent(string cls, bool large) - { - int height = large ? MiniPreviewHeight : CompactThumbHeight; - var cell = new StackPanel - { - Orientation = large ? Orientation.Vertical : Orientation.Horizontal, - Spacing = large ? 2 : 8, - HorizontalAlignment = large ? HorizontalAlignment.Center : HorizontalAlignment.Left, - VerticalAlignment = VerticalAlignment.Center - }; - var bmp = GetMiniBitmap(cls, height); - if (bmp != null) - cell.Children.Add(new Image - { - Source = bmp, Height = height, - Stretch = Stretch.Uniform, - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center - }); - cell.Children.Add(new TextBlock - { - Text = cls, - HorizontalAlignment = large ? HorizontalAlignment.Center : HorizontalAlignment.Left, - VerticalAlignment = VerticalAlignment.Center, - TextAlignment = TextAlignment.Center, - TextTrimming = TextTrimming.CharacterEllipsis - }); - return cell; - } - - private IEnumerable ClassesForCategory(Func? category) => - _db.Textures - .Where(t => category == null || category(CategoryOf(t))) - .Where(t => !_db.IsSetFollower(t)) - .Where(t => !StarterNG.Classes.Settings.Instance.HideArchivalVehicles || !t.ResolvedArchived) - .Select(ClassOf) - .Where(s => !string.IsNullOrEmpty(s)) - .Distinct(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 Func; - RebuildClassCombo(); - } - - private void ClassCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) - { - if (_suppress) return; - string? cls = classCombo.SelectedItem as string; - _classFilter = cls; - - if (cls != null && _categoryFilter == null && - PostSyncing(() => { ApplyFilters(CategoryOfClass(cls), cls); BuildList(); })) - return; - - BuildList(); - } - - private void Combo_OnPointerWheel(object? sender, PointerWheelEventArgs e) - { - if (sender is not ComboBox combo || combo.IsDropDownOpen) - return; - - int count = combo.ItemCount; - if (count == 0) - return; - - int index = combo.SelectedIndex; - if (e.Delta.Y > 0) - index = index <= 0 ? 0 : index - 1; - else if (e.Delta.Y < 0) - index = index < 0 ? 0 : Math.Min(index + 1, count - 1); - else - return; - - combo.SelectedIndex = index; - e.Handled = true; - } - - private void BuildList() - { - - _browserSelected = null; - miniPreview.Source = null; - addVehicleButton.IsEnabled = false; - - vehicleListBox.Items.Clear(); - - string search = searchBox.Text?.Trim() ?? ""; - bool hasSearch = search.Length > 0; - - if (_categoryFilter == null && _classFilter == null && !hasSearch) - return; - - var matched = _db.Textures - .Where(t => PassesFilters(t, search, hasSearch)) - .OrderBy(t => t.TextureMini ?? t.Skinfile, StringComparer.OrdinalIgnoreCase) - .Take(MaxListRows) - .ToList(); - - foreach (var texture in matched) - { - var row = new ListBoxItem - { - Content = BrowserLabel(texture, _db.ResolveSet(texture)), - Tag = texture - }; - row.ContextMenu = BuildBrowserMenu(texture); - vehicleListBox.Items.Add(row); - } - - if (vehicleListBox.Items.Count == 0) - vehicleListBox.Items.Add(new ListBoxItem - { - Content = App.Loc["NoVehicles"], - IsEnabled = false - }); - } - - private void VehicleListBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) - { - if (vehicleListBox.SelectedItem is ListBoxItem { Tag: VehicleTexture texture }) - { - _browserSelected = texture; - miniPreview.Source = GetMiniBitmap(_db.ResolveMiniName(texture), MiniPreviewHeight); - addVehicleButton.IsEnabled = true; - SyncCombosTo(texture); - } - else - { - _browserSelected = null; - miniPreview.Source = null; - addVehicleButton.IsEnabled = false; - } - } - - private ContextMenu BuildBrowserMenu(VehicleTexture texture) - { - var menu = new ContextMenu(); - var copy = new MenuItem { Header = App.Loc["CopyTextureName"] }; - copy.Click += async (_, _) => - { - var top = TopLevel.GetTopLevel(this); - if (top?.Clipboard != null) - await top.Clipboard.SetTextAsync(BrowserName(texture)); - }; - menu.Items.Add(copy); - - var open = new MenuItem { Header = App.Loc["OpenTextureFolder"] }; - open.Click += (_, _) => - { - string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic", - texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/')); - if (!Directory.Exists(dir)) - dir = Path.Combine(Directory.GetCurrentDirectory(), texture.Directory); - if (!Directory.Exists(dir)) - { - StarterNG.Infrastructure.Diagnostics.ReportOnUiThread( - string.Format(App.Loc["FaultFileNotFound"], dir)); - return; - } - try - { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(dir) - { - UseShellExecute = true - }); - } - catch (Exception ex) - { - StarterNG.Infrastructure.Diagnostics.ReportOnUiThread( - $"{dir}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {ex.Message}"); - } - }; - menu.Items.Add(open); - - menu.Items.Add(new Separator()); - var baseItem = new MenuItem { Header = App.Loc["OpenTextureBase"] }; - baseItem.Click += (_, _) => TextureBaseButton_OnClick(null, null!); - menu.Items.Add(baseItem); - - return menu; - } - - private void ShowTextureInfo(VehicleTexture texture, StackPanel target) - { - target.Children.Clear(); - - var full = new List(); - void Row(string label, string? value) - { - if (string.IsNullOrWhiteSpace(value)) return; - string line = $"{label}: {value}"; - full.Add(line); - target.Children.Add(new TextBlock - { - Text = line, - FontSize = 10, - Opacity = 0.85, - TextTrimming = TextTrimming.CharacterEllipsis - }); - } - - Row(App.Loc["TextureInfo"], Base(texture.Skinfile)); - var meta = texture.Meta; - if (meta != null) - { - Row(App.Loc["TexOperator"], meta.Operator); - Row(App.Loc["TexStation"], meta.Depot); - Row(App.Loc["TexRevision"], meta.RevisionDate); - Row(App.Loc["TexWorks"], meta.RevisionPlace); - Row(App.Loc["TexAuthor"], meta.TextureAuthor); - Row(App.Loc["TexPhoto"], meta.PhotoAuthor); - } - ToolTip.SetTip(target, string.Join("\n", full)); - } - - private void VehicleListBox_OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e) - { - if (_browserSelected != null) - ReplaceSelected(_browserSelected); - } - - private void AddVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) - { - if (_browserSelected != null) - AddTexture(_browserSelected); - } - - public void ToolAddAllCategory() => AddAllCategoryButton_OnClick(null, null!); - public void ToolAddUniqueMmd() => AddUniqueMmdButton_OnClick(null, null!); - public void ToolRemoveAllTrains() => RemoveAllTrainsButton_OnClick(null, null!); - - private async void AddAllCategoryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) - { - var all = CurrentBrowserTextures(); - if (all.Count == 0) - { - await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["PickCategoryFirst"]); - return; - } - - foreach (var t in all) - InsertTextureAt(t, _consist.Count); - } - - private async void AddUniqueMmdButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) - { - var all = CurrentBrowserTextures(); - if (all.Count == 0) - { - await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["PickCategoryFirst"]); - return; - } - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var t in all) - { - foreach (string model in ModelsOf(t)) - { - if (!seen.Add(model)) continue; - InsertTextureAt(t, _consist.Count); - - var added = _consist[^1]; - foreach (var car in added.Cars) - car.MmdFile = model; - } - } - } - - private static IEnumerable ModelsOf(VehicleTexture t) - { - string first = string.IsNullOrEmpty(t.Model) ? t.Skinfile : t.Model!; - if (!string.IsNullOrEmpty(first)) - yield return first; - - foreach (var alias in t.Aliases) - if (!string.IsNullOrEmpty(alias.Model)) - yield return alias.Model!; - } - - private async void RemoveAllTrainsButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) - { - if (_listScenery is null || TopLevel.GetTopLevel(this) is not Window owner) - return; - - if (!await MessageBox.Show(owner, App.Loc["RemoveAllTrainsConfirm"], - App.Loc["RemoveAllTrains"], MessageBoxButtons.YesNo)) - return; - - foreach (var ts in _listScenery.Trainsets) - ts.Vehicles = new List(); - - if (_editingTrainset != null) - LoadTrainset(_editingTrainset); - PopulateSceneryConsists(); - RebuildConsist(); - } - - private List CurrentBrowserTextures() - { - string search = searchBox.Text?.Trim() ?? ""; - bool hasSearch = search.Length > 0; - - if (_categoryFilter == null && _classFilter == null && !hasSearch) - return new List(); - - return _db.Textures - .Where(t => PassesFilters(t, search, hasSearch)) - .OrderBy(t => t.TextureMini ?? t.Skinfile, StringComparer.OrdinalIgnoreCase) - .ToList(); - } - - private bool PassesFilters(VehicleTexture t, string search, bool hasSearch) - { - if (_categoryFilter != null && !_categoryFilter(CategoryOf(t))) - return false; - - if (_classFilter != null && - !string.Equals(ClassOf(t), _classFilter, StringComparison.OrdinalIgnoreCase)) - return false; - - if (_db.IsSetFollower(t)) - return false; - - if (StarterNG.Classes.Settings.Instance.HideArchivalVehicles && t.ResolvedArchived) - return false; - - if (hasSearch && !Matches(t, search)) - return false; - - return true; - } - - 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(ClassOf(t)); - } - - 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; - AutoConnectAll(); - RebuildConsist(); - } - - private static string BrowserName(VehicleTexture texture) - { - if (!string.IsNullOrEmpty(texture.TextureMini) && - !string.Equals(texture.TextureMini, texture.ResolvedClass, StringComparison.OrdinalIgnoreCase)) - return texture.TextureMini!; - return Base(texture.Skinfile); - } - - private string BrowserLabel(VehicleTexture texture, IReadOnlyList? set) - { - string name = BrowserName(texture); - - if (!string.IsNullOrEmpty(texture.Meta?.Operator)) - name += $" · {texture.Meta!.Operator}"; - if (set is { Count: > 1 }) - name += $" [{set.Count}]"; - return name; - } - - private void AddTexture(VehicleTexture texture) - { - int at = _consist.Count; - if (_selected != null) - { - int si = _consist.IndexOf(_selected); - if (si >= 0) at = si + 1; - } - InsertTextureAt(texture, at); - } - - private void InsertTextureAt(VehicleTexture texture, int at) - { - 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 - }; - - at = Math.Clamp(at, 0, _consist.Count); - MatchOccupancy(item, at); - _consist.Insert(at, item); - _selected = item; - AutoConnectAll(); - RebuildConsist(); - } - - private void MatchOccupancy(ConsistItem item, int position) - { - item.Driver = eDriverType.Nobody; - var lead = item.Cars.FirstOrDefault(); - if (lead is null) return; - - foreach (var car in item.Cars) - car.DriverType = eDriverType.Nobody; - - var tex = _db.TextureForSkin(lead.SkinFile); - string? cat = tex != null ? CategoryOf(tex) : null; - if (!IsPoweredCategory(cat)) - return; - - bool staffed = _consist.Any(i => - i.Driver is eDriverType.Headdriver or eDriverType.Reardriver); - if (position == 0 || !staffed) - { - item.Driver = eDriverType.Headdriver; - lead.DriverType = eDriverType.Headdriver; - } - } - - private static bool IsPoweredCategory(string? c) => - c is "e" or "s" or "p" or "z" or "a"; - public void RebuildAfterLanguageChange() { - PopulateCategoryCombo(); + _browser.PopulateCategoryCombo(); PopulateSceneryConsists(); RebuildConsist(); - UpdateDetails(); + _details.Refresh(); + } + + private void RebuildConsist() + { + consistStack.Children.Clear(); + _cards.Forget(); + _drag.Reset(); + for (int i = 0; i < _consist.Count; i++) + { + var item = _consist[i]; + consistStack.Children.Add(_cards.BuildCard(item, i)); + + if (i < _consist.Count - 1) + consistStack.Children.Add(_cards.BuildCoupler(item)); + else + consistStack.Children.Add(_cards.BuildCoupler(item, trailing: true)); + } + + emptyHint.IsVisible = _consist.Count == 0; + _details.Refresh(); + UpdateTrainStats(); + WriteBackToScenery(); + } + + private void WriteBackToScenery() + { + if (_consist.EditingTrainset is null) + return; + + _consist.EditingTrainset.Vehicles = _consist.Flatten(); + RefreshConsistListLabels(); + } + + private void UpdateTrainStats() + { + if (_consist.EditingTrainset is null) + { + trainStats.Children.Clear(); + return; + } + + StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields( + _consist.EditingTrainset, + _info.PhysicsFor, + car => _db.TextureForSkin(car.SkinFile) is { } t ? VehicleInfo.CategoryOf(t) : null, + LoadWeights.WeightOf)); + } + + private void RemoveVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + if (_consist.Selected is { } item) + _consist.Remove(item); + } + + private void ConsistFilter_OnChanged(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + if (_suppress) return; + StarterNG.Classes.Settings.Instance.ShowAiVehicles = depotShowAiCheck.IsChecked == true; + StarterNG.Classes.Settings.Instance.DrivableOnly = depotDrivableOnlyCheck.IsChecked == true; + PopulateSceneryConsists(); } private void PopulateSceneryConsists() @@ -946,7 +278,7 @@ public partial class Depot : UserControl AppState.Instance.CurrentScenery = _listScenery; AppState.Instance.CurrentTrainset = trainset; AppState.Instance.StartingVehicleName = TrainsetDisplay.DefaultStartingVehicle(trainset); - LoadTrainset(trainset); + _consist.LoadFrom(trainset); } private static bool IsAiTrainset(Trainset trainset) => @@ -969,229 +301,16 @@ public partial class Depot : UserControl target.Vehicles = vehicles; AppState.Instance.CurrentScenery = _listScenery; AppState.Instance.CurrentTrainset = target; - LoadTrainset(target); + _consist.LoadFrom(target); PopulateSceneryConsists(); } - private void PopulateWarehouse() - { - string? current = (warehouseList.SelectedItem as ListBoxItem)?.Tag as string; - warehouseList.Items.Clear(); - - foreach (var preset in PresetStore.All()) - { - var entry = new ListBoxItem - { - Tag = preset.Name, - Content = new TextBlock - { - Text = preset.Name, - TextTrimming = TextTrimming.CharacterEllipsis - } - }; - warehouseList.Items.Add(entry); - if (current is not null && string.Equals(current, preset.Name, StringComparison.Ordinal)) - warehouseList.SelectedItem = entry; - } - - warehouseList.ContextMenu ??= BuildWarehouseMenu(); - removeFromWarehouseButton.IsEnabled = warehouseList.Items.Count > 0; - } - - private string? SelectedPresetName() => - (warehouseList.SelectedItem as ListBoxItem)?.Tag as string; - - private ContextMenu BuildWarehouseMenu() - { - var menu = new ContextMenu(); - menu.Opening += (_, _) => - { - menu.Items.Clear(); - bool hasPreset = SelectedPresetName() != null; - bool hasTarget = _editingTrainset != null; - - void Add(string key, Action run, bool enabled = true) - { - var mi = new MenuItem { Header = App.Loc[key], IsEnabled = enabled }; - mi.Click += (_, _) => run(); - menu.Items.Add(mi); - } - - Add("WarehouseApply", ApplySelectedPreset, hasPreset && hasTarget); - Add("WarehouseAppend", () => AppendWarehouseButton_OnClick(null, null!), hasPreset && hasTarget); - Add("ChangeName", () => RenameWarehouseButton_OnClick(null, null!), hasPreset); - - menu.Items.Add(new Separator()); - Add("PresetImportMagazyn", () => ImportMagazynButton_OnClick(null, null!)); - Add("WarehouseImportFile", () => ImportFileButton_OnClick(null, null!)); - - menu.Items.Add(new Separator()); - var sort = new MenuItem { Header = App.Loc["PresetSort"] }; - var byName = new MenuItem - { - Header = (PresetStore.SortMode == PresetSort.Name ? "\u25CF " : "\u25CB ") + App.Loc["PresetSortName"] - }; - byName.Click += (_, _) => { PresetStore.SortMode = PresetSort.Name; PopulateWarehouse(); }; - var byTrack = new MenuItem - { - Header = (PresetStore.SortMode == PresetSort.Track ? "\u25CF " : "\u25CB ") + App.Loc["PresetSortTrack"] - }; - byTrack.Click += (_, _) => { PresetStore.SortMode = PresetSort.Track; PopulateWarehouse(); }; - sort.Items.Add(byName); - sort.Items.Add(byTrack); - menu.Items.Add(sort); - }; - return menu; - } - - private async void AddToWarehouseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) - { - if (_editingTrainset is not { } trainset) - return; - - string name = trainset.Name; - if (string.IsNullOrWhiteSpace(name)) - name = App.Loc["PresetEmpty"]; - - try - { - PresetStore.Save(name, ConsistText.Serialize(trainset)); - } - catch (Exception ex) - { - await StarterNG.Infrastructure.Diagnostics.ReportAsync(PresetStore.FilePath, ex); - return; - } - PopulateWarehouse(); - } - - private void WarehouseList_OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e) => - ApplySelectedPreset(); - - private void ApplySelectedPreset() - { - if (SelectedPresetName() is not { } name || _editingTrainset is not { } target) - return; - - var preset = PresetStore.All().FirstOrDefault(p => - string.Equals(p.Name, name, StringComparison.Ordinal)); - if (preset is null) - return; - - var vehicles = ConsistText.VehiclesFrom(preset.Entry); - if (vehicles != null) - ApplyConsist(target, vehicles); - } - - private void RenameWarehouseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) - { - if (SelectedPresetName() is not { } name) - return; - - var preset = PresetStore.All().FirstOrDefault(p => - string.Equals(p.Name, name, StringComparison.Ordinal)); - if (preset is null) - return; - - var nameBox = new TextBox { Text = name, MinWidth = 240 }; - var ok = new Button { Content = App.Loc["Ok"], Cursor = _hand }; - ok.Classes.Add("Accent"); - - var panel = new StackPanel { Spacing = 6, Margin = new Thickness(8) }; - panel.Children.Add(new TextBlock - { - Text = App.Loc["PresetNamePrompt"], - FontWeight = FontWeight.Bold, - FontSize = 12 - }); - panel.Children.Add(nameBox); - panel.Children.Add(ok); - - var flyout = new Flyout { Content = panel }; - ok.Click += (_, _) => - { - string? fresh = nameBox.Text?.Trim(); - if (!string.IsNullOrEmpty(fresh) && !string.Equals(fresh, name, StringComparison.Ordinal)) - { - PresetStore.Save(fresh, preset.Entry); - PresetStore.Delete(name); - PopulateWarehouse(); - } - flyout.Hide(); - }; - flyout.ShowAt(warehouseList); - nameBox.Focus(); - } - - private void AppendWarehouseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) - { - if (SelectedPresetName() is not { } name || _editingTrainset is not { } target) - return; - - var preset = PresetStore.All().FirstOrDefault(p => - string.Equals(p.Name, name, StringComparison.Ordinal)); - var vehicles = ConsistText.VehiclesFrom(preset?.Entry); - if (vehicles is null || vehicles.Count == 0) - return; - - var merged = new List(target.Vehicles); - merged.AddRange(vehicles); - ApplyConsist(target, merged); - } - - private async void ImportFileButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) - { - if (TopLevel.GetTopLevel(this) is not { } top) - return; - - var picked = await top.StorageProvider.OpenFilePickerAsync( - new Avalonia.Platform.Storage.FilePickerOpenOptions - { - Title = App.Loc["WarehouseImportFile"], - AllowMultiple = false - }); - - string? path = picked.FirstOrDefault()?.Path.LocalPath; - if (string.IsNullOrEmpty(path)) - return; - - int added = PresetStore.ImportMagazyn(path); - PopulateWarehouse(); - if (added == 0) - await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["WarehouseImportNothing"]); - } - - private async void RemoveFromWarehouseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) - { - if (SelectedPresetName() is not { } name) - return; - - try - { - PresetStore.Delete(name); - } - catch (Exception ex) - { - await StarterNG.Infrastructure.Diagnostics.ReportAsync(PresetStore.FilePath, ex); - return; - } - PopulateWarehouse(); - } - - private async void ImportMagazynButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) - { - int added = PresetStore.ImportMagazyn(); - PopulateWarehouse(); - if (added == 0) - await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["WarehouseImportNothing"]); - } - private void RemoveAllVehicles(Trainset target) { target.Vehicles = new List(); AppState.Instance.CurrentScenery = _listScenery; AppState.Instance.CurrentTrainset = target; - LoadTrainset(target); + _consist.LoadFrom(target); PopulateSceneryConsists(); } @@ -1220,1628 +339,15 @@ public partial class Depot : UserControl } } - private Dynamic MakeDynamic(VehicleTexture texture) - { - string skin = Base(texture.Skinfile); - return new Dynamic - { - RangeMax = -1, - RangeMin = 0, - Name = MakeUniqueName(skin), - DataFolder = StripDynamicPrefix(texture.Directory), - SkinFile = skin, - MmdFile = string.IsNullOrEmpty(texture.Model) ? skin : texture.Model!, - Offset = 0f, - DriverType = eDriverType.Nobody, - Coupling = new Coupling { Flags = Coupling.Mechanical | Coupling.BrakePipe }, - HasVelocity = true, - MiniName = _db.ResolveMiniName(texture) - }; - } - - private string MakeUniqueName(string baseName) - { - if (string.IsNullOrWhiteSpace(baseName)) baseName = "vehicle"; - baseName = baseName.Replace(' ', '_'); - - var used = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var item in _consist) - foreach (var c in item.Cars) - if (!string.IsNullOrEmpty(c.Name)) - used.Add(c.Name!); - - var scn = _listScenery ?? AppState.Instance.CurrentScenery; - if (scn != null) - { - foreach (string n in scn.LooseVehicleNames) - used.Add(n); - foreach (var ts in scn.Trainsets) - { - if (ReferenceEquals(ts, _editingTrainset)) continue; - foreach (var v in ts.Vehicles) - if (!string.IsNullOrEmpty(v.Name)) - used.Add(v.Name!); - } - } - - string candidate; - do - candidate = $"{baseName}_{++_nameCounter}"; - while (used.Contains(candidate)); - return candidate; - } - - private static string StripDynamicPrefix(string directory) - { - string d = directory.Replace('\\', '/').TrimEnd('/'); - const string prefix = "dynamic/"; - if (d.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - d = d[prefix.Length..]; - return d; - } - - private void MoveLeft(ConsistItem item) - { - int i = _consist.IndexOf(item); - if (i > 0) - { - (_consist[i - 1], _consist[i]) = (_consist[i], _consist[i - 1]); - RebuildConsist(); - } - } - - private void MoveRight(ConsistItem item) - { - int i = _consist.IndexOf(item); - if (i >= 0 && i < _consist.Count - 1) - { - (_consist[i + 1], _consist[i]) = (_consist[i], _consist[i + 1]); - RebuildConsist(); - } - } - - private void RemoveItem(ConsistItem item) - { - int gap = _consist.IndexOf(item); - _consist.Remove(item); - - if (ReferenceEquals(_selected, item)) - _selected = _consist.Count == 0 - ? null - : _consist[Math.Clamp(gap, 0, _consist.Count - 1)]; - - RebuildConsist(); - } - - private void FlipItem(ConsistItem item) - { - int index = _consist.IndexOf(item); - if (index < 0) return; - - int first = index; - while (first > 0 && HoldsTail(_consist[first - 1])) - first--; - - int last = index; - while (last + 1 < _consist.Count && HoldsTail(_consist[last])) - last++; - - for (int k = first; k <= last; k++) - { - var card = _consist[k]; - card.Flipped = !card.Flipped; - foreach (var car in card.Cars) - car.Offset = car.Offset >= 0 ? -1f : 0f; - } - - if (last > first) - _consist.Reverse(first, last - first + 1); - - AutoConnectAll(); - RebuildConsist(); - } - - private void CycleDriver(ConsistItem item) - { - var car = ActiveCar(item); - car.DriverType = car.DriverType switch - { - eDriverType.Nobody => eDriverType.Headdriver, - eDriverType.Headdriver => eDriverType.Reardriver, - eDriverType.Reardriver => eDriverType.Passenger, - _ => eDriverType.Nobody - }; - item.Driver = UnitDriver(item.Cars); - if (ReferenceEquals(_selected, item)) - SyncStartingVehicle(); - RebuildConsist(); - AppState.Instance.NotifyChanged(); - } - - private void SplitItem(ConsistItem item) - { - int i = _consist.IndexOf(item); - if (i < 0) return; - - var order = item.Flipped - ? Enumerable.Reverse(item.Cars).ToList() - : item.Cars; - - _consist.RemoveAt(i); - for (int c = 0; c < order.Count; c++) - { - _consist.Insert(i + c, new ConsistItem - { - Cars = new List { order[c] }, - Grouped = false, - Flipped = item.Flipped, - Driver = order[c].DriverType - }); - } - _selected = _consist[i]; - RebuildConsist(); - } - - private static bool HoldsNext(Dynamic car) => - car.Coupling.Has(Coupling.WorkshopLock) || car.Coupling.Locked; - - private void JoinItem(ConsistItem item) - { - int index = _consist.IndexOf(item); - if (index < 0) return; - - int first = index; - while (first > 0 && HoldsTail(_consist[first - 1])) - first--; - - int last = index; - - bool inUnit = first < index || HoldsTail(_consist[index]); - if (!inUnit && last + 1 < _consist.Count) - { - var card = _consist[last]; - if (card.Cars.Count > 0) - TailCar(card).Coupling.Set(Coupling.WorkshopLock, true); - last++; - } - - while (last + 1 < _consist.Count && HoldsTail(_consist[last])) - last++; - - if (last == first) return; - - var cars = new List(); - var driver = eDriverType.Nobody; - for (int k = first; k <= last; k++) - { - cars.AddRange(_consist[k].Cars); - if (driver == eDriverType.Nobody) - driver = _consist[k].Driver; - } - - bool flipped = _consist[first].Flipped; - if (flipped) - cars.Reverse(); - - var joined = new ConsistItem - { - Cars = cars, - Grouped = true, - Flipped = flipped, - Driver = driver - }; - - _consist.RemoveRange(first, last - first + 1); - _consist.Insert(first, joined); - _selected = joined; - - AutoConnectAll(); - RebuildConsist(); - } - - private Dynamic ActiveCar(ConsistItem item) => - _selectedCar != null && item.Cars.Contains(_selectedCar) ? _selectedCar : item.Cars[0]; - - private IEnumerable UnitTargets(ConsistItem item) => item.Cars; - - private IEnumerable MemberTargets(ConsistItem item) => - item.Cars.Count == 1 ? item.Cars : new[] { ActiveCar(item) }; - - private static Dynamic TailCar(ConsistItem item) => - item.Flipped ? item.Cars[0] : item.Cars[^1]; - - private static Dynamic HeadCar(ConsistItem item) => - item.Flipped ? item.Cars[^1] : item.Cars[0]; - - private static bool HoldsTail(ConsistItem item) => - item.Cars.Count > 0 && HoldsNext(TailCar(item)); - - private bool CanFormUnit(ConsistItem left, ConsistItem right) - { - if (left.Cars.Count == 0 || right.Cars.Count == 0) - return false; - - var tail = TailCar(left); - var head = HeadCar(right); - - var lp = PhysicsFor(tail); - var rp = PhysicsFor(head); - int leftMax = lp == null ? 3 : (left.Flipped ? lp.AllowedFlagA : lp.AllowedFlagB); - int rightMax = rp == null ? 3 : (right.Flipped ? rp.AllowedFlagB : rp.AllowedFlagA); - - return (leftMax & rightMax & Coupling.WorkshopLock) != 0 || SameSet(tail, head); - } - - private bool SameSet(Dynamic a, Dynamic b) - { - if (IsUnitCar(a) && IsUnitCar(b) && UnitKey(a) == UnitKey(b)) - return true; - - string? ua = _db.TextureForSkin(a.SkinFile)?.Uuid; - string? ub = _db.TextureForSkin(b.SkinFile)?.Uuid; - if (string.IsNullOrEmpty(ua) || string.IsNullOrEmpty(ub)) - return false; - - return _db.SetByTextureUuid.TryGetValue(ua!, out var sa) && - _db.SetByTextureUuid.TryGetValue(ub!, out var sb) && - ReferenceEquals(sa, sb) && sa.TextureRefs.Count > 1; - } - - private void RebuildConsist() - { - consistStack.Children.Clear(); - _memberChrome.Clear(); - _driverChrome.Clear(); - _dropGap.Forget(); - _dropTracker.Reset(); - for (int i = 0; i < _consist.Count; i++) - { - var item = _consist[i]; - consistStack.Children.Add(BuildCard(item, i)); - - if (i < _consist.Count - 1) - consistStack.Children.Add(BuildCoupler(item)); - else - consistStack.Children.Add(BuildCoupler(item, trailing: true)); - } - - emptyHint.IsVisible = _consist.Count == 0; - UpdateDetails(); - UpdateTrainStats(); - WriteBackToScenery(); - } - - private void WriteBackToScenery() - { - if (_editingTrainset is null) - return; - - var flat = new List(); - foreach (var item in _consist) - { - var cars = item.Flipped ? item.Cars.AsEnumerable().Reverse().ToList() : item.Cars; - foreach (var car in cars) - flat.Add(car); - } - _editingTrainset.Vehicles = flat; - RefreshConsistListLabels(); - } - - private Physics? PhysicsFor(Dynamic car) - { - string? dbModel = _db.TextureForSkin(car.SkinFile)?.Model; - return Physics.For(car.DataFolder, dbModel) - ?? Physics.For(car.DataFolder, car.MmdFile) - ?? Physics.For(car.DataFolder, car.SkinFile); - } - - private void AutoConnectAll() - { - var flat = new List<(Dynamic car, bool flipped)>(); - foreach (var item in _consist) - { - var cars = item.Flipped ? item.Cars.AsEnumerable().Reverse() : item.Cars; - foreach (var c in cars) - flat.Add((c, item.Flipped)); - } - - for (int i = 0; i < flat.Count - 1; i++) - { - var (left, lf) = flat[i]; - var (right, rf) = flat[i + 1]; - - var lp = PhysicsFor(left); - var rp = PhysicsFor(right); - - int leftMax = lp == null ? 3 : (lf ? lp.AllowedFlagA : lp.AllowedFlagB); - int rightMax = rp == null ? 3 : (rf ? rp.AllowedFlagB : rp.AllowedFlagA); - int common = leftMax & rightMax; - - string lct = lp == null ? "" : (lf ? lp.ControlTypeA : lp.ControlTypeB); - string rct = rp == null ? "" : (rf ? rp.ControlTypeB : rp.ControlTypeA); - if ((common & Coupling.ControlMU) != 0 && - !string.Equals(lct, rct, StringComparison.OrdinalIgnoreCase)) - common &= ~Coupling.ControlMU; - - left.Coupling.Flags = left.Coupling.Locked ? -common : common; - } - } - - private void UpdateTrainStats() - { - if (_editingTrainset is null) - { - trainStats.Children.Clear(); - return; - } - - StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields( - _editingTrainset, - PhysicsFor, - car => _db.TextureForSkin(car.SkinFile) is { } t ? CategoryOf(t) : null, - LoadWeight)); - } - - private Control BuildCard(ConsistItem item, int index) - { - bool selected = ReferenceEquals(_selected, item); - - var minis = new StackPanel - { - 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, strict: true) - ?? GetMiniBitmap(car.SkinFile, ConsistThumbHeight, strict: true) - ?? GetMiniBitmap(car.MiniName, ConsistThumbHeight); - if (bmp != null) - { - var img = new Image { Source = bmp, Height = ConsistThumbHeight, Stretch = Stretch.Uniform }; - if (item.Flipped) - { - img.RenderTransform = new ScaleTransform(-1, 1); - img.RenderTransformOrigin = RelativePoint.Center; - } - - minis.Children.Add(MemberFrame(img, item, car, CardCaption(item, car, index))); - } - else - { - minis.Children.Add(MemberFrame(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 - } - }, item, car, CardCaption(item, car, index))); - } - } - - var nameBlock = new TextBlock - { - Text = item.Grouped && item.Cars.Count > 1 ? UnitLabel(item) : string.Empty, - FontSize = 11, - MaxWidth = Math.Max(120, item.Cars.Count * 96), - TextWrapping = TextWrapping.Wrap, - TextAlignment = TextAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center, - IsVisible = item.Grouped && item.Cars.Count > 1 - }; - - var controls = new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 2, - HorizontalAlignment = HorizontalAlignment.Center - }; - 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["TipMoveRight"], () => MoveRight(item))); - - if (item.Grouped && item.Cars.Count > 1) - controls.Children.Add(SmallButton("⧉", App.Loc["TipSplit"], () => SplitItem(item))); - else if ((index + 1 < _consist.Count && - (HoldsTail(item) || CanFormUnit(item, _consist[index + 1]))) || - (index > 0 && HoldsTail(_consist[index - 1]))) - controls.Children.Add(SmallButton("⛓", App.Loc["TipJoin"], () => JoinItem(item))); - - var remove = SmallButton("✕", App.Loc["RemoveSelectedVehicle"], () => RemoveItem(item)); - remove.Foreground = new SolidColorBrush(Color.Parse("#E05252")); - remove.FontWeight = FontWeight.Bold; - controls.Children.Add(remove); - - var inner = new StackPanel { Spacing = 4 }; - inner.Children.Add(minis); - inner.Children.Add(nameBlock); - inner.Children.Add(controls); - - var card = new Border - { - Margin = new Thickness(2, 0), - Padding = new Thickness(6), - CornerRadius = new CornerRadius(6), - BorderThickness = new Thickness(selected ? 2 : 1), - BorderBrush = selected ? CardBorderSel : CardBorder, - Background = selected ? CardBgSel : Brushes.Transparent, - Cursor = _hand, - Child = inner, - Tag = item - }; - card.PointerPressed += (_, e) => - { - - if (!e.GetCurrentPoint(card).Properties.IsLeftButtonPressed) - return; - - if (e.Source is Control src && src.GetVisualAncestors().OfType