Initial depot view

This commit is contained in:
2026-06-11 23:01:49 +02:00
parent ba9c16e4c4
commit 6dc9b77a24
9 changed files with 1142 additions and 3 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -6,7 +6,39 @@
<x:String x:Key="ScenarioDescription">Opis scenariusza</x:String>
<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>

View File

@@ -44,7 +44,15 @@ public class Scenery
foreach (string trainsetEntry in trainsetEntries)
{
Trainsets.Add(new Trainset(trainsetEntry));
try
{
Trainsets.Add(new Trainset(trainsetEntry));
}
catch
{
// skip malformed trainset entries so one bad scenery
// doesn't break loading the rest
}
}
}
}

View File

@@ -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
};
}

View 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();
}

View File

@@ -27,7 +27,7 @@
<materialIcons:MaterialIcon Kind="Train" />
</sukiUi:SukiSideMenuItem.Icon>
<sukiUi:SukiSideMenuItem.PageContent>
<Label>MaSzyna</Label>
<views:Depot />
</sukiUi:SukiSideMenuItem.PageContent>
</sukiUi:SukiSideMenuItem>

View 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>

View 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();
}
}