mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-07-17 17:09:19 +02:00
Initial depot view
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEncoding_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F1675dc7b710feeeb3e0bc8728be8a947537155c199480fb23b776e81d459_003FEncoding_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AInputElement_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F3a2dc32e584992fd11eb82d1a1da165d8e52d7c736fb38d90ce2e9de8ff31ab_003FInputElement_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMethodBaseInvoker_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F53428d95742549e8968df3e76ebd1d7de8e928_003Fce_003Fb6adf085_003FMethodBaseInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANumber_002EParsing_002Ecs_002Fl_003AC_0021_003FUsers_003FHirek_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fde8a243f75215d958afa80cf80a41b4981a44efea7db93bffcdaf7bd6ba378c0_003FNumber_002EParsing_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANumber_002EParsing_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fde8a243f75215d958afa80cf80a41b4981a44efea7db93bffcdaf7bd6ba378c0_003FNumber_002EParsing_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AProcess_002EWindows_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe4b93d8bba7294f6676125d63f2c965fc3e4293b7fac7f8460a64f1950f84d9_003FProcess_002EWindows_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStarterNG_002EMainWindow_002Eg_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fe3e158158d7662e3d588342a349d6a29b4cf57_003FStarterNG_002EMainWindow_002Eg_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
|
||||
@@ -7,6 +7,38 @@
|
||||
<x:String x:Key="Consist">Consist</x:String>
|
||||
<x:String x:Key="Start">Start</x:String>
|
||||
|
||||
<!-- Depot / consist creation -->
|
||||
<x:String x:Key="VehicleDatabase">Vehicle database</x:String>
|
||||
<x:String x:Key="Search">Search...</x:String>
|
||||
<x:String x:Key="LoadFromScenery">Load consist from scenery</x:String>
|
||||
<x:String x:Key="NewConsist">New consist</x:String>
|
||||
<x:String x:Key="SelectedVehicle">Selected vehicle</x:String>
|
||||
<x:String x:Key="Vehicle">Vehicle</x:String>
|
||||
<x:String x:Key="Crew">with crew</x:String>
|
||||
<x:String x:Key="Reversed">reversed</x:String>
|
||||
<x:String x:Key="NoVehicles">No vehicles found in databases/vehicles/.</x:String>
|
||||
<x:String x:Key="ConsistEmpty">Add vehicles from the database on the left, or load an existing consist from a scenery file.</x:String>
|
||||
<x:String x:Key="TipMoveLeft">Move left</x:String>
|
||||
<x:String x:Key="TipMoveRight">Move right</x:String>
|
||||
<x:String x:Key="TipFlip">Reverse vehicle</x:String>
|
||||
<x:String x:Key="TipRemove">Remove from consist</x:String>
|
||||
<x:String x:Key="TipDriver">Change crew (cab 1 / cab 2 / passenger / none)</x:String>
|
||||
<x:String x:Key="Scenery">Scenery</x:String>
|
||||
<x:String x:Key="Category">Category</x:String>
|
||||
<x:String x:Key="Class">Class</x:String>
|
||||
<x:String x:Key="All">All</x:String>
|
||||
<x:String x:Key="CatDiesel">Diesel</x:String>
|
||||
<x:String x:Key="CatElectric">Electric</x:String>
|
||||
<x:String x:Key="CatEMU">EMU</x:String>
|
||||
<x:String x:Key="CatDMU">DMU</x:String>
|
||||
<x:String x:Key="CatCarriagesA">Carriages A</x:String>
|
||||
<x:String x:Key="CatCarriagesB">Carriages B</x:String>
|
||||
<x:String x:Key="Brakes">Brakes</x:String>
|
||||
<x:String x:Key="Loads">Loads</x:String>
|
||||
<x:String x:Key="Coupling">Coupling</x:String>
|
||||
<x:String x:Key="SelectVehicleHint">Select a vehicle in the consist to edit it.</x:String>
|
||||
<x:String x:Key="ComingSoon">Editing options coming soon.</x:String>
|
||||
|
||||
<!-- Settings -->
|
||||
<x:String x:Key="General">General</x:String>
|
||||
|
||||
|
||||
@@ -7,6 +7,38 @@
|
||||
<x:String x:Key="Consist">Zestawienie</x:String>
|
||||
<x:String x:Key="Start">Uruchom</x:String>
|
||||
|
||||
<!-- Lokomotywownia / tworzenie składu -->
|
||||
<x:String x:Key="VehicleDatabase">Baza pojazdów</x:String>
|
||||
<x:String x:Key="Search">Szukaj...</x:String>
|
||||
<x:String x:Key="LoadFromScenery">Wczytaj skład ze scenerii</x:String>
|
||||
<x:String x:Key="NewConsist">Nowy skład</x:String>
|
||||
<x:String x:Key="SelectedVehicle">Wybrany pojazd</x:String>
|
||||
<x:String x:Key="Vehicle">Pojazd</x:String>
|
||||
<x:String x:Key="Crew">z obsadą</x:String>
|
||||
<x:String x:Key="Reversed">obrócony</x:String>
|
||||
<x:String x:Key="NoVehicles">Nie znaleziono pojazdów w databases/vehicles/.</x:String>
|
||||
<x:String x:Key="ConsistEmpty">Dodaj pojazdy z bazy po lewej stronie lub wczytaj istniejący skład z pliku scenerii.</x:String>
|
||||
<x:String x:Key="TipMoveLeft">Przesuń w lewo</x:String>
|
||||
<x:String x:Key="TipMoveRight">Przesuń w prawo</x:String>
|
||||
<x:String x:Key="TipFlip">Obróć pojazd</x:String>
|
||||
<x:String x:Key="TipRemove">Usuń ze składu</x:String>
|
||||
<x:String x:Key="TipDriver">Zmień obsadę (kabina 1 / kabina 2 / pasażer / brak)</x:String>
|
||||
<x:String x:Key="Scenery">Sceneria</x:String>
|
||||
<x:String x:Key="Category">Kategoria</x:String>
|
||||
<x:String x:Key="Class">Klasa</x:String>
|
||||
<x:String x:Key="All">Wszystkie</x:String>
|
||||
<x:String x:Key="CatDiesel">Spalinowe</x:String>
|
||||
<x:String x:Key="CatElectric">Elektryczne</x:String>
|
||||
<x:String x:Key="CatEMU">EZT</x:String>
|
||||
<x:String x:Key="CatDMU">SZT</x:String>
|
||||
<x:String x:Key="CatCarriagesA">Wagony A</x:String>
|
||||
<x:String x:Key="CatCarriagesB">Wagony B</x:String>
|
||||
<x:String x:Key="Brakes">Hamulce</x:String>
|
||||
<x:String x:Key="Loads">Ładunki</x:String>
|
||||
<x:String x:Key="Coupling">Sprzęg</x:String>
|
||||
<x:String x:Key="SelectVehicleHint">Wybierz pojazd w składzie, aby go edytować.</x:String>
|
||||
<x:String x:Key="ComingSoon">Opcje edycji wkrótce.</x:String>
|
||||
|
||||
<!-- Ustawienia -->
|
||||
<x:String x:Key="General">Ogólne</x:String>
|
||||
|
||||
|
||||
@@ -43,8 +43,16 @@ public class Scenery
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,4 +167,30 @@ public class Dynamic
|
||||
public eDriverType DriverType;
|
||||
public byte couplingData;
|
||||
public string Tail;
|
||||
|
||||
// --- depot / consist-builder extras (not part of the .scn syntax) ---
|
||||
|
||||
/// <summary>Explicit miniature name (from the vehicle database). Falls back to SkinFile.</summary>
|
||||
public string? MiniName;
|
||||
|
||||
/// <summary>Whether the vehicle is visually reversed in the consist.</summary>
|
||||
public bool Flipped;
|
||||
|
||||
/// <summary>Deep copy, used when importing a trainset into the editable consist.</summary>
|
||||
public Dynamic Clone() => new Dynamic
|
||||
{
|
||||
RangeMax = RangeMax,
|
||||
RangeMin = RangeMin,
|
||||
Name = Name,
|
||||
DataFolder = DataFolder,
|
||||
SkinFile = SkinFile,
|
||||
MmdFile = MmdFile,
|
||||
PathName = PathName,
|
||||
Offset = Offset,
|
||||
DriverType = DriverType,
|
||||
couplingData = couplingData,
|
||||
Tail = Tail,
|
||||
MiniName = MiniName,
|
||||
Flipped = Flipped
|
||||
};
|
||||
}
|
||||
232
StarterNG/Classes/VehicleDatabase.cs
Normal file
232
StarterNG/Classes/VehicleDatabase.cs
Normal file
@@ -0,0 +1,232 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
|
||||
// =====================================================================
|
||||
// Vehicle JSON database (databases/vehicles/*.json)
|
||||
// Format documented in vehicleEntryDoc.md (schema_version 1).
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>Root object of a single vehicle JSON file.</summary>
|
||||
public class VehicleEntry
|
||||
{
|
||||
[JsonPropertyName("uuid")] public string? Uuid { get; set; }
|
||||
[JsonPropertyName("schema_version")] public int SchemaVersion { get; set; }
|
||||
[JsonPropertyName("groups")] public List<VehicleGroup> Groups { get; set; } = new();
|
||||
[JsonPropertyName("textures")] public List<VehicleTexture> Textures { get; set; } = new();
|
||||
[JsonPropertyName("sets")] public List<VehicleSet> Sets { get; set; } = new();
|
||||
[JsonPropertyName("unknown")] public List<string> Unknown { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Merged database root (databases/vehicles/vehicles.json).</summary>
|
||||
public class VehicleEntryCollection
|
||||
{
|
||||
[JsonPropertyName("vehicles")] public List<VehicleEntry> Vehicles { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Legacy header / vehicle category group.</summary>
|
||||
public class VehicleGroup
|
||||
{
|
||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||
[JsonPropertyName("category")] public string? Category { get; set; }
|
||||
[JsonPropertyName("mini")] public string? Mini { get; set; }
|
||||
[JsonPropertyName("archived")] public bool Archived { get; set; }
|
||||
[JsonPropertyName("implicit")] public bool Implicit { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A single .mat skin entry.</summary>
|
||||
public class VehicleTexture
|
||||
{
|
||||
[JsonPropertyName("uuid")] public string? Uuid { get; set; }
|
||||
[JsonPropertyName("directory")] public string Directory { get; set; } = "";
|
||||
[JsonPropertyName("skinfile")] public string Skinfile { get; set; } = "";
|
||||
[JsonPropertyName("model")] public string? Model { get; set; }
|
||||
[JsonPropertyName("group")] public string? Group { get; set; }
|
||||
[JsonPropertyName("mini_ref")] public string? MiniRef { get; set; }
|
||||
[JsonPropertyName("texture_mini")] public string? TextureMini { get; set; }
|
||||
[JsonPropertyName("wreck")] public bool Wreck { get; set; }
|
||||
[JsonPropertyName("aliases")] public List<VehicleAlias> Aliases { get; set; } = new();
|
||||
[JsonPropertyName("meta")] public VehicleMeta? Meta { get; set; }
|
||||
|
||||
/// <summary>Full skin path = directory + skinfile.</summary>
|
||||
[JsonIgnore] public string FullPath => Directory + Skinfile;
|
||||
}
|
||||
|
||||
/// <summary>Alternate mapping from a malformed multi-= legacy line.</summary>
|
||||
public class VehicleAlias
|
||||
{
|
||||
[JsonPropertyName("model")] public string? Model { get; set; }
|
||||
[JsonPropertyName("group")] public string? Group { get; set; }
|
||||
[JsonPropertyName("mini_ref")] public string? MiniRef { get; set; }
|
||||
[JsonPropertyName("texture_mini")] public string? TextureMini { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Parsed metadata from the legacy // comment section.</summary>
|
||||
public class VehicleMeta
|
||||
{
|
||||
[JsonPropertyName("raw")] public string? Raw { get; set; }
|
||||
[JsonPropertyName("version")] public string? Version { get; set; }
|
||||
[JsonPropertyName("vehicle")] public string? Vehicle { get; set; }
|
||||
[JsonPropertyName("operator")] public string? Operator { get; set; }
|
||||
[JsonPropertyName("depot")] public string? Depot { get; set; }
|
||||
[JsonPropertyName("revision_date")] public string? RevisionDate { get; set; }
|
||||
[JsonPropertyName("revision_place")] public string? RevisionPlace { get; set; }
|
||||
[JsonPropertyName("texture_author")] public string? TextureAuthor { get; set; }
|
||||
[JsonPropertyName("photo_author")] public string? PhotoAuthor { get; set; }
|
||||
[JsonPropertyName("extra")] public List<string> Extra { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated, in-memory view over every vehicle JSON file found in
|
||||
/// databases/vehicles/. Provides lookup helpers for the depot UI.
|
||||
/// </summary>
|
||||
public class VehicleDatabase
|
||||
{
|
||||
public List<VehicleTexture> Textures { get; } = new();
|
||||
public Dictionary<string, VehicleGroup> GroupsById { get; } = new();
|
||||
public List<VehicleSet> Sets { get; } = new();
|
||||
|
||||
/// <summary>Texture-uuid -> automatic-consist set that contains it.</summary>
|
||||
public Dictionary<string, VehicleSet> SetByTextureUuid { get; } = new();
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Loads the database. When a merged vehicles.json is present it is used,
|
||||
/// otherwise every per-vehicle *.json file in the directory is read.
|
||||
/// Never throws: unreadable files are skipped.
|
||||
/// </summary>
|
||||
public void Load(string directory = "databases/vehicles/")
|
||||
{
|
||||
Textures.Clear();
|
||||
GroupsById.Clear();
|
||||
Sets.Clear();
|
||||
SetByTextureUuid.Clear();
|
||||
|
||||
if (!Directory.Exists(directory))
|
||||
return;
|
||||
|
||||
string merged = Path.Combine(directory, "vehicles.json");
|
||||
if (File.Exists(merged))
|
||||
{
|
||||
LoadMerged(merged);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (string file in Directory.GetFiles(directory, "*.json"))
|
||||
LoadEntryFile(file);
|
||||
}
|
||||
|
||||
BuildSetIndex();
|
||||
}
|
||||
|
||||
private void LoadMerged(string file)
|
||||
{
|
||||
try
|
||||
{
|
||||
var collection = JsonSerializer.Deserialize<VehicleEntryCollection>(
|
||||
File.ReadAllText(file), JsonOpts);
|
||||
if (collection?.Vehicles is null) return;
|
||||
foreach (var entry in collection.Vehicles)
|
||||
Ingest(entry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore malformed merged database
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadEntryFile(string file)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = JsonSerializer.Deserialize<VehicleEntry>(
|
||||
File.ReadAllText(file), JsonOpts);
|
||||
if (entry is not null)
|
||||
Ingest(entry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore malformed single-vehicle file
|
||||
}
|
||||
}
|
||||
|
||||
private void Ingest(VehicleEntry entry)
|
||||
{
|
||||
foreach (var group in entry.Groups)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(group.Id))
|
||||
GroupsById.TryAdd(group.Id, group);
|
||||
}
|
||||
|
||||
foreach (var texture in entry.Textures)
|
||||
{
|
||||
// skip wrecks from the standard browser list
|
||||
if (texture.Wreck) continue;
|
||||
Textures.Add(texture);
|
||||
}
|
||||
|
||||
Sets.AddRange(entry.Sets);
|
||||
}
|
||||
|
||||
private void BuildSetIndex()
|
||||
{
|
||||
foreach (var set in Sets)
|
||||
{
|
||||
if (set.TextureRefs is null) continue;
|
||||
foreach (string uuid in set.TextureRefs)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(uuid))
|
||||
SetByTextureUuid[uuid] = set;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Best miniature name for a texture (texture_mini -> mini_ref -> group mini).</summary>
|
||||
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))
|
||||
return grp.Mini;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Header label for the group a texture belongs to.</summary>
|
||||
public string GroupHeader(string? groupId)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(groupId) && GroupsById.TryGetValue(groupId, out var grp))
|
||||
{
|
||||
string mini = string.IsNullOrEmpty(grp.Mini) ? grp.Id : grp.Mini!;
|
||||
return string.IsNullOrEmpty(grp.Category) ? mini : $"{mini} ({grp.Category})";
|
||||
}
|
||||
return groupId ?? "";
|
||||
}
|
||||
|
||||
/// <summary>Resolves a miniature .bmp path under textures/mini/, or null if missing.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Automatic consist definition (legacy ^x grouping).</summary>
|
||||
public class VehicleSet
|
||||
{
|
||||
[JsonPropertyName("uuid")] public string? Uuid { get; set; }
|
||||
[JsonPropertyName("mode")] public string? Mode { get; set; }
|
||||
[JsonPropertyName("count")] public int Count { get; set; }
|
||||
[JsonPropertyName("texture_refs")] public List<string> TextureRefs { get; set; } = new();
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
<materialIcons:MaterialIcon Kind="Train" />
|
||||
</sukiUi:SukiSideMenuItem.Icon>
|
||||
<sukiUi:SukiSideMenuItem.PageContent>
|
||||
<Label>MaSzyna</Label>
|
||||
<views:Depot />
|
||||
</sukiUi:SukiSideMenuItem.PageContent>
|
||||
</sukiUi:SukiSideMenuItem>
|
||||
|
||||
|
||||
95
StarterNG/Views/Depot.axaml
Normal file
95
StarterNG/Views/Depot.axaml
Normal file
@@ -0,0 +1,95 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:sukiUi="clr-namespace:SukiUI.Controls;assembly=SukiUI"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:starterNg="clr-namespace:StarterNG"
|
||||
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="700"
|
||||
x:Class="StarterNG.Views.Depot">
|
||||
<Grid Margin="16" ColumnSpacing="16" RowSpacing="16">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="340" />
|
||||
<ColumnDefinition />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition />
|
||||
<RowDefinition Height="220" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Vehicle database browser (left, rows 0-1) -->
|
||||
<sukiUi:GroupBox Grid.RowSpan="2"
|
||||
Header="{Binding [VehicleDatabase], Source={x:Static starterNg:App.Loc}}">
|
||||
<DockPanel LastChildFill="True">
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,*" ColumnSpacing="6" Margin="0,0,0,6">
|
||||
<ComboBox Name="categoryCombo" Grid.Column="0" HorizontalAlignment="Stretch"
|
||||
SelectionChanged="CategoryCombo_OnSelectionChanged" />
|
||||
<ComboBox Name="classCombo" Grid.Column="1" HorizontalAlignment="Stretch"
|
||||
SelectionChanged="ClassCombo_OnSelectionChanged" />
|
||||
</Grid>
|
||||
<TextBox Name="searchBox" DockPanel.Dock="Top" Margin="0,0,0,8"
|
||||
Watermark="{Binding [Search], Source={x:Static starterNg:App.Loc}}"
|
||||
TextChanged="SearchBox_OnTextChanged" />
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Name="browserStack" Spacing="2" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</sukiUi:GroupBox>
|
||||
|
||||
<!-- Scenery + load consist (top-right) -->
|
||||
<sukiUi:GroupBox Grid.Column="1" Grid.Row="0"
|
||||
Header="{Binding [LoadFromScenery], Source={x:Static starterNg:App.Loc}}">
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="6">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center"
|
||||
Text="{Binding [Scenery], Source={x:Static starterNg:App.Loc}}" />
|
||||
<ComboBox Name="sceneryCombo" Grid.Row="0" Grid.Column="1" HorizontalAlignment="Stretch"
|
||||
SelectionChanged="SceneryCombo_OnSelectionChanged" />
|
||||
<ComboBox Name="sceneryConsistCombo" Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2"
|
||||
HorizontalAlignment="Stretch" MaxDropDownHeight="420"
|
||||
PlaceholderText="{Binding [LoadFromScenery], Source={x:Static starterNg:App.Loc}}"
|
||||
SelectionChanged="SceneryConsistCombo_OnSelectionChanged" />
|
||||
</Grid>
|
||||
</sukiUi:GroupBox>
|
||||
|
||||
<!-- Brakes / Loads editor (middle-right) -->
|
||||
<sukiUi:GroupBox Grid.Column="1" Grid.Row="1"
|
||||
Header="{Binding [SelectedVehicle], Source={x:Static starterNg:App.Loc}}">
|
||||
<DockPanel LastChildFill="True">
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
|
||||
<Button Name="newConsistButton" Classes="Flat" Cursor="Hand"
|
||||
Click="NewConsist_OnClick"
|
||||
Content="{Binding [NewConsist], Source={x:Static starterNg:App.Loc}}" />
|
||||
<TextBlock Name="summaryLabel" VerticalAlignment="Center" Opacity="0.8" FontSize="12" />
|
||||
</StackPanel>
|
||||
<TabControl>
|
||||
<TabItem Header="{Binding [Brakes], Source={x:Static starterNg:App.Loc}}">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<TextBlock Name="brakesContent" TextWrapping="Wrap" FontSize="12"
|
||||
LineHeight="18" Margin="4" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem Header="{Binding [Loads], Source={x:Static starterNg:App.Loc}}">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<TextBlock Name="loadsContent" TextWrapping="Wrap" FontSize="12"
|
||||
LineHeight="18" Margin="4" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
</sukiUi:GroupBox>
|
||||
|
||||
<!-- Consist (full-width bottom, horizontally scrollable) -->
|
||||
<sukiUi:GroupBox Grid.Row="2" Grid.ColumnSpan="2"
|
||||
Header="{Binding [Consist], Source={x:Static starterNg:App.Loc}}">
|
||||
<Panel>
|
||||
<TextBlock Name="emptyHint" VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap" MaxWidth="520" Opacity="0.6" TextAlignment="Center"
|
||||
Text="{Binding [ConsistEmpty], Source={x:Static starterNg:App.Loc}}" />
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel Name="consistStack" Orientation="Horizontal"
|
||||
VerticalAlignment="Center" Spacing="0" />
|
||||
</ScrollViewer>
|
||||
</Panel>
|
||||
</sukiUi:GroupBox>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
713
StarterNG/Views/Depot.axaml.cs
Normal file
713
StarterNG/Views/Depot.axaml.cs
Normal file
@@ -0,0 +1,713 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using StarterNG.Classes;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
public partial class Depot : UserControl
|
||||
{
|
||||
private readonly VehicleDatabase _db = new();
|
||||
private readonly List<Scenery> _sceneries = new();
|
||||
|
||||
// The consist currently being edited (belongs to the selected scenery).
|
||||
private readonly List<Dynamic> _consist = new();
|
||||
private Dynamic? _selected;
|
||||
|
||||
private readonly Dictionary<string, Bitmap?> _miniCache = new();
|
||||
private readonly Cursor _hand = new(StandardCursorType.Hand);
|
||||
private int _nameCounter;
|
||||
|
||||
// suppresses combo SelectionChanged handlers while we populate them
|
||||
private bool _suppress;
|
||||
|
||||
// active database filters
|
||||
private string[]? _categoryFilter; // legacy category letters, null = all
|
||||
private string? _classFilter; // group mini, 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"));
|
||||
|
||||
// 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 =
|
||||
{
|
||||
("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
|
||||
};
|
||||
|
||||
// Placeholder coupling-bit labels (couplingData byte). Finished later.
|
||||
private static readonly string[] CouplingBits =
|
||||
{ "Coupler", "Brake pipe", "Brake tank", "Control", "Gangway", "High voltage", "Heating", "Aux" };
|
||||
|
||||
public Depot()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
if (Design.IsDesignMode)
|
||||
return;
|
||||
|
||||
LoadDatabase();
|
||||
LoadSceneries();
|
||||
|
||||
PopulateCategoryCombo(); // -> populates class combo -> builds browser
|
||||
PopulateSceneryCombo(); // -> populates consist combo for the first scenery
|
||||
RebuildConsist();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- loading
|
||||
|
||||
private void LoadDatabase()
|
||||
{
|
||||
try { _db.Load("databases/vehicles/"); }
|
||||
catch { /* leave database empty on failure */ }
|
||||
}
|
||||
|
||||
private void LoadSceneries()
|
||||
{
|
||||
try
|
||||
{
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
catch { /* no scenery folder */ }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ category combo
|
||||
|
||||
private void PopulateCategoryCombo()
|
||||
{
|
||||
_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 });
|
||||
categoryCombo.SelectedIndex = 0;
|
||||
_suppress = false;
|
||||
|
||||
_categoryFilter = null;
|
||||
RebuildClassCombo();
|
||||
}
|
||||
|
||||
private void RebuildClassCombo()
|
||||
{
|
||||
_suppress = true;
|
||||
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 });
|
||||
|
||||
classCombo.SelectedIndex = 0;
|
||||
_suppress = false;
|
||||
|
||||
_classFilter = null;
|
||||
BuildBrowser();
|
||||
}
|
||||
|
||||
private IEnumerable<string> 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!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(m => m, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private void CategoryCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_suppress) return;
|
||||
_categoryFilter = (categoryCombo.SelectedItem as ComboBoxItem)?.Tag as string[];
|
||||
RebuildClassCombo();
|
||||
}
|
||||
|
||||
private void ClassCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_suppress) return;
|
||||
_classFilter = (classCombo.SelectedItem as ComboBoxItem)?.Tag as string;
|
||||
BuildBrowser();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- vehicle browser
|
||||
|
||||
private void BuildBrowser()
|
||||
{
|
||||
browserStack.Children.Clear();
|
||||
|
||||
string search = searchBox.Text?.Trim() ?? "";
|
||||
bool hasSearch = search.Length > 0;
|
||||
|
||||
var visible = _db.Textures.Where(t => PassesFilters(t, search, hasSearch)).ToList();
|
||||
|
||||
var grouped = visible
|
||||
.GroupBy(t => t.Group ?? "")
|
||||
.OrderBy(g => _db.GroupHeader(g.Key), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var list = group.ToList();
|
||||
string header = string.IsNullOrEmpty(group.Key)
|
||||
? App.Loc["Others"]
|
||||
: _db.GroupHeader(group.Key);
|
||||
|
||||
var content = new StackPanel { Spacing = 2 };
|
||||
var expander = new Expander
|
||||
{
|
||||
Header = $"{header} ({list.Count})",
|
||||
IsExpanded = hasSearch || _classFilter != null,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
};
|
||||
expander.Content = content;
|
||||
|
||||
bool populated = false;
|
||||
void Populate()
|
||||
{
|
||||
if (populated) return;
|
||||
populated = true;
|
||||
foreach (var texture in list)
|
||||
content.Children.Add(BuildBrowserItem(texture));
|
||||
}
|
||||
|
||||
if (expander.IsExpanded)
|
||||
Populate();
|
||||
expander.Expanded += (_, _) => Populate();
|
||||
|
||||
browserStack.Children.Add(expander);
|
||||
}
|
||||
|
||||
if (browserStack.Children.Count == 0)
|
||||
{
|
||||
browserStack.Children.Add(new TextBlock
|
||||
{
|
||||
Text = App.Loc["NoVehicles"],
|
||||
Opacity = 0.6,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = new Thickness(4)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 (_classFilter != null)
|
||||
{
|
||||
string? mini = GroupMini(t);
|
||||
if (!string.Equals(mini, _classFilter, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasSearch && !Matches(t, search))
|
||||
return false;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
var label = new TextBlock
|
||||
{
|
||||
Text = BrowserLabel(texture),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
FontSize = 12,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
|
||||
var stack = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
|
||||
stack.Children.Add(img);
|
||||
stack.Children.Add(label);
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Content = stack,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
HorizontalContentAlignment = HorizontalAlignment.Left,
|
||||
Cursor = _hand
|
||||
};
|
||||
button.Classes.Add("Basic");
|
||||
button.Click += (_, _) => AddFromTexture(texture);
|
||||
ToolTip.SetTip(button, texture.FullPath);
|
||||
return button;
|
||||
}
|
||||
|
||||
private string BrowserLabel(VehicleTexture texture)
|
||||
{
|
||||
string name = !string.IsNullOrEmpty(texture.TextureMini)
|
||||
? texture.TextureMini!
|
||||
: Path.GetFileNameWithoutExtension(texture.Skinfile);
|
||||
if (!string.IsNullOrEmpty(texture.Meta?.Operator))
|
||||
name += $" · {texture.Meta!.Operator}";
|
||||
return name;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- scenery combos
|
||||
|
||||
private void PopulateSceneryCombo()
|
||||
{
|
||||
_suppress = true;
|
||||
sceneryCombo.Items.Clear();
|
||||
foreach (var scenery in _sceneries)
|
||||
{
|
||||
sceneryCombo.Items.Add(new ComboBoxItem
|
||||
{
|
||||
Content = Path.GetFileNameWithoutExtension(scenery.Path),
|
||||
Tag = scenery
|
||||
});
|
||||
}
|
||||
if (sceneryCombo.Items.Count > 0)
|
||||
sceneryCombo.SelectedIndex = 0;
|
||||
_suppress = false;
|
||||
|
||||
PopulateConsistCombo(_sceneries.FirstOrDefault());
|
||||
}
|
||||
|
||||
private void PopulateConsistCombo(Scenery? scenery)
|
||||
{
|
||||
_suppress = true;
|
||||
sceneryConsistCombo.Items.Clear();
|
||||
|
||||
if (scenery != null)
|
||||
{
|
||||
foreach (var trainset in scenery.Trainsets)
|
||||
{
|
||||
if (trainset.Vehicles.Count == 0)
|
||||
continue;
|
||||
if (trainset.Vehicles.All(v => string.IsNullOrWhiteSpace(v.Name)))
|
||||
continue;
|
||||
|
||||
sceneryConsistCombo.Items.Add(new ComboBoxItem
|
||||
{
|
||||
Content = BuildConsistPreview(trainset),
|
||||
Tag = trainset
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sceneryConsistCombo.SelectedItem = null;
|
||||
_suppress = false;
|
||||
}
|
||||
|
||||
// Combo item: row of mini thumbnails on top, skin names underneath.
|
||||
private Control BuildConsistPreview(Trainset trainset)
|
||||
{
|
||||
var minis = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2 };
|
||||
var names = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 };
|
||||
|
||||
foreach (var v in trainset.Vehicles)
|
||||
{
|
||||
var bmp = GetMiniBitmap(v.MiniName) ?? GetMiniBitmap(v.SkinFile);
|
||||
if (bmp != null)
|
||||
minis.Children.Add(new Image { Source = bmp, Height = 26, Stretch = Stretch.Uniform });
|
||||
else
|
||||
minis.Children.Add(new Border
|
||||
{
|
||||
Height = 26, Width = 44,
|
||||
Background = new SolidColorBrush(Color.Parse("#22808080")),
|
||||
CornerRadius = new CornerRadius(3)
|
||||
});
|
||||
|
||||
names.Children.Add(new TextBlock { Text = v.SkinFile, FontSize = 10, Opacity = 0.8 });
|
||||
}
|
||||
|
||||
var panel = new StackPanel { Spacing = 3 };
|
||||
panel.Children.Add(minis);
|
||||
panel.Children.Add(names);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private void SceneryCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_suppress) return;
|
||||
var scenery = (sceneryCombo.SelectedItem as ComboBoxItem)?.Tag as Scenery;
|
||||
PopulateConsistCombo(scenery);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ consist edits
|
||||
|
||||
private void AddFromTexture(VehicleTexture texture)
|
||||
{
|
||||
string skin = Path.GetFileNameWithoutExtension(texture.Skinfile);
|
||||
var dyn = new Dynamic
|
||||
{
|
||||
RangeMax = -1,
|
||||
RangeMin = -1,
|
||||
Name = MakeUniqueName(skin),
|
||||
DataFolder = StripDynamicPrefix(texture.Directory),
|
||||
SkinFile = skin,
|
||||
MmdFile = string.IsNullOrEmpty(texture.Model) ? skin : texture.Model!,
|
||||
Offset = 0f,
|
||||
DriverType = eDriverType.Nobody,
|
||||
couplingData = 3,
|
||||
Tail = "",
|
||||
MiniName = _db.ResolveMiniName(texture)
|
||||
};
|
||||
_consist.Add(dyn);
|
||||
_selected = dyn;
|
||||
RebuildConsist();
|
||||
}
|
||||
|
||||
private string MakeUniqueName(string baseName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseName)) baseName = "vehicle";
|
||||
baseName = baseName.Replace(' ', '_');
|
||||
return $"{baseName}_{++_nameCounter}";
|
||||
}
|
||||
|
||||
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(Dynamic d)
|
||||
{
|
||||
int i = _consist.IndexOf(d);
|
||||
if (i > 0)
|
||||
{
|
||||
(_consist[i - 1], _consist[i]) = (_consist[i], _consist[i - 1]);
|
||||
RebuildConsist();
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveRight(Dynamic d)
|
||||
{
|
||||
int i = _consist.IndexOf(d);
|
||||
if (i >= 0 && i < _consist.Count - 1)
|
||||
{
|
||||
(_consist[i + 1], _consist[i]) = (_consist[i], _consist[i + 1]);
|
||||
RebuildConsist();
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveVehicle(Dynamic d)
|
||||
{
|
||||
_consist.Remove(d);
|
||||
if (ReferenceEquals(_selected, d)) _selected = null;
|
||||
RebuildConsist();
|
||||
}
|
||||
|
||||
private void FlipVehicle(Dynamic d)
|
||||
{
|
||||
d.Flipped = !d.Flipped;
|
||||
RebuildConsist();
|
||||
}
|
||||
|
||||
private void CycleDriver(Dynamic d)
|
||||
{
|
||||
d.DriverType = d.DriverType switch
|
||||
{
|
||||
eDriverType.Nobody => eDriverType.Headdriver,
|
||||
eDriverType.Headdriver => eDriverType.Reardriver,
|
||||
eDriverType.Reardriver => eDriverType.Passenger,
|
||||
_ => eDriverType.Nobody
|
||||
};
|
||||
RebuildConsist();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- consist render
|
||||
|
||||
private void RebuildConsist()
|
||||
{
|
||||
consistStack.Children.Clear();
|
||||
for (int i = 0; i < _consist.Count; i++)
|
||||
{
|
||||
var d = _consist[i];
|
||||
consistStack.Children.Add(BuildCard(d));
|
||||
if (i < _consist.Count - 1)
|
||||
consistStack.Children.Add(BuildCoupler(d));
|
||||
}
|
||||
|
||||
emptyHint.IsVisible = _consist.Count == 0;
|
||||
UpdateSummary();
|
||||
UpdateDetails();
|
||||
}
|
||||
|
||||
private Control BuildCard(Dynamic d)
|
||||
{
|
||||
bool selected = ReferenceEquals(_selected, d);
|
||||
|
||||
Control visual;
|
||||
var bmp = GetMiniBitmap(d.MiniName) ?? GetMiniBitmap(d.SkinFile);
|
||||
if (bmp != null)
|
||||
{
|
||||
var img = new Image { Height = 48, Stretch = Stretch.Uniform };
|
||||
img.Source = bmp;
|
||||
if (d.Flipped)
|
||||
{
|
||||
img.RenderTransform = new ScaleTransform(-1, 1);
|
||||
img.RenderTransformOrigin = RelativePoint.Center;
|
||||
}
|
||||
visual = img;
|
||||
}
|
||||
else
|
||||
{
|
||||
visual = new Border
|
||||
{
|
||||
Height = 48,
|
||||
Width = 96,
|
||||
Background = new SolidColorBrush(Color.Parse("#22808080")),
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = d.SkinFile,
|
||||
FontSize = 10,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextAlignment = TextAlignment.Center
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var nameBlock = new TextBlock
|
||||
{
|
||||
Text = d.Name,
|
||||
FontSize = 11,
|
||||
MaxWidth = 110,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
};
|
||||
|
||||
var controls = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
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)));
|
||||
|
||||
var inner = new StackPanel { Spacing = 4, Width = 122 };
|
||||
inner.Children.Add(visual);
|
||||
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
|
||||
};
|
||||
card.PointerPressed += (_, _) =>
|
||||
{
|
||||
_selected = d;
|
||||
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)
|
||||
{
|
||||
var glyph = new TextBlock
|
||||
{
|
||||
Text = "≣",
|
||||
FontSize = 18,
|
||||
Opacity = 0.7,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
};
|
||||
var coupler = new Border
|
||||
{
|
||||
Width = 18,
|
||||
Padding = new Thickness(2),
|
||||
Cursor = _hand,
|
||||
Background = Brushes.Transparent,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = glyph
|
||||
};
|
||||
ToolTip.SetTip(coupler, BuildCouplingBox(d));
|
||||
ToolTip.SetShowDelay(coupler, 150);
|
||||
return coupler;
|
||||
}
|
||||
|
||||
private Control BuildCouplingBox(Dynamic d)
|
||||
{
|
||||
var panel = new StackPanel { Spacing = 2 };
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = App.Loc["Coupling"],
|
||||
FontWeight = FontWeight.Bold,
|
||||
FontSize = 11,
|
||||
Margin = new Thickness(0, 0, 0, 2)
|
||||
});
|
||||
|
||||
for (int i = 0; i < CouplingBits.Length; i++)
|
||||
{
|
||||
panel.Children.Add(new CheckBox
|
||||
{
|
||||
Content = CouplingBits[i],
|
||||
IsChecked = (d.couplingData & (1 << i)) != 0,
|
||||
FontSize = 11
|
||||
});
|
||||
}
|
||||
|
||||
return new Border { Padding = new Thickness(8), Child = panel };
|
||||
}
|
||||
|
||||
private Button SmallButton(string glyph, string tip, Action onClick)
|
||||
{
|
||||
var btn = new Button
|
||||
{
|
||||
Content = glyph,
|
||||
FontSize = 12,
|
||||
Padding = new Thickness(5, 1),
|
||||
MinWidth = 0,
|
||||
Cursor = _hand
|
||||
};
|
||||
btn.Classes.Add("Basic");
|
||||
ToolTip.SetTip(btn, tip);
|
||||
btn.Click += (_, _) => onClick();
|
||||
return btn;
|
||||
}
|
||||
|
||||
private Button DriverButton(Dynamic d)
|
||||
{
|
||||
(string glyph, string color) = d.DriverType switch
|
||||
{
|
||||
eDriverType.Headdriver => ("1", "#4CAF50"),
|
||||
eDriverType.Reardriver => ("2", "#FF9800"),
|
||||
eDriverType.Passenger => ("P", "#2196F3"),
|
||||
_ => ("–", "#888888")
|
||||
};
|
||||
|
||||
var btn = SmallButton(glyph, App.Loc["TipDriver"], () => CycleDriver(d));
|
||||
btn.Foreground = new SolidColorBrush(Color.Parse(color));
|
||||
btn.FontWeight = FontWeight.Bold;
|
||||
return btn;
|
||||
}
|
||||
|
||||
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"]}";
|
||||
}
|
||||
|
||||
// Brakes / Loads tabs are placeholders bound to the selected vehicle.
|
||||
private void UpdateDetails()
|
||||
{
|
||||
if (_selected is null)
|
||||
{
|
||||
brakesContent.Text = App.Loc["SelectVehicleHint"];
|
||||
loadsContent.Text = App.Loc["SelectVehicleHint"];
|
||||
return;
|
||||
}
|
||||
|
||||
var d = _selected;
|
||||
brakesContent.Text =
|
||||
$"{App.Loc["Vehicle"]}: {d.Name}\n" +
|
||||
$"coupling: {d.couplingData}\n\n" +
|
||||
$"{App.Loc["ComingSoon"]}";
|
||||
loadsContent.Text =
|
||||
$"{App.Loc["Vehicle"]}: {d.Name}\n\n" +
|
||||
$"{App.Loc["ComingSoon"]}";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- minis
|
||||
|
||||
private Bitmap? GetMiniBitmap(string? name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return null;
|
||||
if (_miniCache.TryGetValue(name, out var cached))
|
||||
return cached;
|
||||
|
||||
string? path = VehicleDatabase.MiniPath(name);
|
||||
Bitmap? bmp = null;
|
||||
if (path != null)
|
||||
{
|
||||
try { bmp = new Bitmap(path); }
|
||||
catch { bmp = null; }
|
||||
}
|
||||
_miniCache[name] = bmp;
|
||||
return bmp;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ events
|
||||
|
||||
private void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e)
|
||||
{
|
||||
if (_suppress) return;
|
||||
BuildBrowser();
|
||||
}
|
||||
|
||||
private void NewConsist_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
_consist.Clear();
|
||||
_selected = null;
|
||||
sceneryConsistCombo.SelectedItem = null;
|
||||
RebuildConsist();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user