consist editor improvements

This commit is contained in:
2026-06-13 00:54:37 +02:00
parent 6dc9b77a24
commit 0e82841a56
16 changed files with 1081 additions and 250 deletions

View File

@@ -1,10 +1,14 @@
using System;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Markup.Xaml.Styling;
using Avalonia.Threading;
using StarterNG.Classes;
using StarterNG.Services;
namespace StarterNG;
@@ -54,7 +58,30 @@ public partial class App : Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
// Code page 1250 is needed to parse scenery files (done during load).
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var splash = new SplashWindow();
desktop.MainWindow = splash;
splash.Show();
// Progress<T> marshals callbacks back to this (UI) thread.
var progress = new Progress<LoadStatus>(splash.Report);
Task.Run(async () =>
{
GameData.Instance.Load(progress);
await Task.Delay(400); // let the completed bar be visible briefly
}).ContinueWith(_ =>
{
Dispatcher.UIThread.Post(() =>
{
var main = new MainWindow();
desktop.MainWindow = main; // keep a main window before closing the splash
main.Show();
splash.Close();
});
});
}
base.OnFrameworkInitializationCompleted();

View File

@@ -33,11 +33,36 @@
<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="CatElectricLoco">Electric locomotive</x:String>
<x:String x:Key="CatDieselLoco">Diesel locomotive</x:String>
<x:String x:Key="CatSteamLoco">Steam locomotive</x:String>
<x:String x:Key="CatRailbus">Railbus</x:String>
<x:String x:Key="CatDraisine">Draisine</x:String>
<x:String x:Key="CatWork">Work vehicles</x:String>
<x:String x:Key="CatPrototype">Prototype</x:String>
<x:String x:Key="CatTram">Tram</x:String>
<x:String x:Key="CatCar">Car</x:String>
<x:String x:Key="CatBus">Bus</x:String>
<x:String x:Key="CatTruck">Truck</x:String>
<x:String x:Key="CatPeople">People</x:String>
<x:String x:Key="CatAnimals">Animals</x:String>
<x:String x:Key="CatWagons">Wagons</x:String>
<x:String x:Key="CatOther">Other</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>
<x:String x:Key="Split">Split</x:String>
<x:String x:Key="TipSplit">Split the unit into individual cars</x:String>
<x:String x:Key="AddVehicle">Add</x:String>
<x:String x:Key="TipAddVehicle">Add this vehicle to the consist</x:String>
<x:String x:Key="TipAddUnit">Add the whole unit to the consist</x:String>
<!-- Loading splash -->
<x:String x:Key="LoadingVehicles">Loading vehicle database</x:String>
<x:String x:Key="LoadingSceneries">Loading sceneries</x:String>
<x:String x:Key="LoadingDone">Starting…</x:String>
<!-- Settings -->
<x:String x:Key="General">General</x:String>

View File

@@ -33,11 +33,36 @@
<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="CatElectricLoco">Lokomotywa elektryczna</x:String>
<x:String x:Key="CatDieselLoco">Lokomotywa spalinowa</x:String>
<x:String x:Key="CatSteamLoco">Parowóz</x:String>
<x:String x:Key="CatRailbus">Szynobus</x:String>
<x:String x:Key="CatDraisine">Drezyna</x:String>
<x:String x:Key="CatWork">Pojazdy robocze</x:String>
<x:String x:Key="CatPrototype">Prototyp</x:String>
<x:String x:Key="CatTram">Tramwaj</x:String>
<x:String x:Key="CatCar">Samochód osobowy</x:String>
<x:String x:Key="CatBus">Autobus</x:String>
<x:String x:Key="CatTruck">Samochód ciężarowy</x:String>
<x:String x:Key="CatPeople">Ludzie</x:String>
<x:String x:Key="CatAnimals">Zwierzęta</x:String>
<x:String x:Key="CatWagons">Wagony</x:String>
<x:String x:Key="CatOther">Inne</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>
<x:String x:Key="Split">Rozdziel</x:String>
<x:String x:Key="TipSplit">Rozdziel zespół na pojedyncze człony</x:String>
<x:String x:Key="AddVehicle">Dodaj</x:String>
<x:String x:Key="TipAddVehicle">Dodaj ten pojazd do składu</x:String>
<x:String x:Key="TipAddUnit">Dodaj cały zespół do składu</x:String>
<!-- Ekran ładowania -->
<x:String x:Key="LoadingVehicles">Wczytywanie bazy pojazdów</x:String>
<x:String x:Key="LoadingSceneries">Wczytywanie scenerii</x:String>
<x:String x:Key="LoadingDone">Uruchamianie…</x:String>
<!-- Ustawienia -->
<x:String x:Key="General">Ogólne</x:String>

View File

@@ -0,0 +1,15 @@
namespace StarterNG.Classes;
/// <summary>
/// Shared selection state across views: the scenery and trainset currently in
/// focus. The depot edits this trainset in place, so changes made in the depot
/// are reflected on the scenery (both views share the same Scenery/Trainset
/// objects from <see cref="GameData"/>).
/// </summary>
public sealed class AppState
{
public static AppState Instance { get; } = new();
public Scenery? CurrentScenery { get; set; }
public Trainset? CurrentTrainset { get; set; }
}

View File

@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace StarterNG.Classes;
public enum LoadPhase
{
Vehicles,
Sceneries,
Done
}
/// <summary>A single progress update emitted while the game data loads.</summary>
public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail);
/// <summary>
/// Loads and holds the data shared across the launcher (vehicle database and
/// parsed sceneries) so it is parsed once, at startup, behind the splash —
/// instead of inside each view's constructor.
/// </summary>
public sealed class GameData
{
public static GameData Instance { get; } = new();
public VehicleDatabase Vehicles { get; } = new();
public List<Scenery> Sceneries { get; } = new();
public bool Loaded { get; private set; }
/// <summary>
/// Parses the vehicle database and all sceneries, reporting progress.
/// Runs synchronously; call it from a background thread. Never throws.
/// </summary>
public void Load(IProgress<LoadStatus>? progress = null,
string vehiclesDir = "databases/vehicles/",
string sceneryDir = "scenery/")
{
if (Loaded)
return;
var vehicleFiles = VehicleDatabase.EnumerateFiles(vehiclesDir);
var scnFiles = EnumerateScenery(sceneryDir);
int total = Math.Max(1, vehicleFiles.Count + scnFiles.Count);
int done = 0;
// Phase 1 - vehicle database
Vehicles.BeginLoad();
foreach (string file in vehicleFiles)
{
progress?.Report(new LoadStatus((double)done / total, LoadPhase.Vehicles,
Path.GetFileName(file)));
try { Vehicles.LoadFile(file); }
catch { /* skip malformed file */ }
done++;
}
Vehicles.EndLoad();
// Phase 2 - sceneries
foreach (string file in scnFiles)
{
progress?.Report(new LoadStatus((double)done / total, LoadPhase.Sceneries,
Path.GetFileName(file)));
try { Sceneries.Add(new Scenery(file)); }
catch { /* skip unreadable scenery */ }
done++;
}
progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null));
Loaded = true;
}
private static List<string> EnumerateScenery(string sceneryDir)
{
if (!Directory.Exists(sceneryDir))
return new List<string>();
return Directory.GetFiles(sceneryDir, "*.scn")
.Where(p => !Path.GetFileName(p).StartsWith("$"))
.ToList();
}
}

View File

@@ -11,6 +11,11 @@ public class Scenery
public List<Trainset> Trainsets;
public string Group;
public string Path;
// The file content with each trainset block replaced by a {{i}} placeholder,
// used to rebuild the .scn on export.
private readonly string _template;
public Scenery(string path)
{
this.Path = path;
@@ -41,18 +46,23 @@ public class Scenery
trainsetEntries.Add(match.Value);
return $"{{{{{idx++}}}}}";
});
_template = content;
// 1:1 with placeholders - the Trainset ctor never throws (unparsable
// blocks are kept verbatim), so indices stay aligned for export.
foreach (string trainsetEntry in trainsetEntries)
{
try
{
Trainsets.Add(new Trainset(trainsetEntry));
}
catch
{
// skip malformed trainset entries so one bad scenery
// doesn't break loading the rest
}
}
Trainsets.Add(new Trainset(trainsetEntry));
}
/// <summary>
/// Rebuilds the full .scn content with the (possibly modified) trainsets
/// substituted back into their original positions.
/// </summary>
public string BuildExportContent()
{
string result = _template;
for (int i = 0; i < Trainsets.Count; i++)
result = result.Replace("{{" + i + "}}", Trainsets[i].ToSceneryEntry());
return result;
}
}

View File

@@ -0,0 +1,51 @@
namespace StarterNG.Classes;
/// <summary>
/// Application settings backing the Settings view. Load/Save are stubs for now;
/// the real persistence will be implemented later.
/// </summary>
public sealed class Settings
{
public static Settings Instance { get; } = new();
// General
public string Language = "English";
public bool Fullscreen;
public bool PauseWhenInactive;
public bool PauseOnStart;
public int CursorSensitivity = 3;
public bool InvertMouseHorizontal;
public bool InvertMouseVertical;
// Other
public string ExecutablePath = "eu07.exe"; // game executable used to launch
public bool SelectExeAutomatically = true;
public bool DebugMode;
public bool VirtualShunting;
// Graphics (subset)
public string Resolution = "1280x720";
public bool VSync = true;
public bool RenderShadows = true;
// Sound
public bool SoundEnabled = true;
public int Volume = 100;
// Starter
public bool AutoCloseStarter;
public bool LargeThumbnails;
public bool AutoExpandSceneryTree;
/// <summary>Loads settings from disk. TODO: implement persistence.</summary>
public void Load()
{
// intentionally empty for now
}
/// <summary>Persists settings to disk. TODO: implement persistence.</summary>
public void Save()
{
// intentionally empty for now
}
}

View File

@@ -22,11 +22,31 @@ public class Trainset
public float Velocity;
public string Description;
public List<Dynamic> Vehicles;
/// <summary>Original .scn text of this trainset block (incl. endtrainset).</summary>
public string RawEntry;
/// <summary>True if the block parsed cleanly; false blocks are exported verbatim.</summary>
public bool Parsed;
public Trainset(string trainsetEntry)
{
RawEntry = trainsetEntry;
Vehicles = new List<Dynamic>();
Description = "";
try
{
ParseEntry(trainsetEntry);
Parsed = true;
}
catch
{
Parsed = false;
}
}
private void ParseEntry(string trainsetEntry)
{
List<string> tokens = Regex
.Matches(trainsetEntry, @"/\*[\s\S]*?\*/|//[^\r\n]*|[^\s\r\n]+")
.Select(m => m.Value)
@@ -113,6 +133,13 @@ public class Trainset
}
}
/// <summary>
/// The .scn text for this trainset for export. Unparsed blocks are written
/// back verbatim; parsed blocks are regenerated from the current vehicles.
/// </summary>
public string ToSceneryEntry() =>
Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry;
public string GetTrainsetEntry()
{
string entry = "";
@@ -120,8 +147,8 @@ public class Trainset
entry += "trainset ";
entry += this.Name + " ";
entry += this.Track + " ";
entry += this.Offset + " ";
entry += this.Velocity + " ";
entry += this.Offset.ToString(CultureInfo.InvariantCulture) + " ";
entry += this.Velocity.ToString(CultureInfo.InvariantCulture) + " ";
entry += "\n";
foreach (Dynamic vehicle in Vehicles)
{

View File

@@ -94,6 +94,12 @@ public class VehicleDatabase
/// <summary>Texture-uuid -> automatic-consist set that contains it.</summary>
public Dictionary<string, VehicleSet> SetByTextureUuid { get; } = new();
/// <summary>Texture-uuid -> texture (includes wrecks, so set refs resolve).</summary>
public Dictionary<string, VehicleTexture> TextureByUuid { get; } = new();
/// <summary>skinfile (without extension, lower-case) -> texture.</summary>
public Dictionary<string, VehicleTexture> TextureBySkin { get; } = new();
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
@@ -102,32 +108,55 @@ public class VehicleDatabase
};
/// <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.
/// Loads the database in one call. When a merged vehicles.json is present
/// it is used, otherwise every per-vehicle *.json file is read.
/// Never throws: unreadable files are skipped.
/// </summary>
public void Load(string directory = "databases/vehicles/")
{
BeginLoad();
foreach (string file in EnumerateFiles(directory))
LoadFile(file);
EndLoad();
}
/// <summary>Resets the aggregate before an incremental load.</summary>
public void BeginLoad()
{
Textures.Clear();
GroupsById.Clear();
Sets.Clear();
SetByTextureUuid.Clear();
TextureByUuid.Clear();
TextureBySkin.Clear();
}
/// <summary>Finalises an incremental load (builds lookup indexes).</summary>
public void EndLoad() => BuildSetIndex();
/// <summary>
/// Files that make up the database: the merged vehicles.json if present,
/// otherwise every per-vehicle *.json. Used to drive load progress.
/// </summary>
public static List<string> EnumerateFiles(string directory = "databases/vehicles/")
{
if (!Directory.Exists(directory))
return;
return new List<string>();
string merged = Path.Combine(directory, "vehicles.json");
if (File.Exists(merged))
{
LoadMerged(merged);
}
else
{
foreach (string file in Directory.GetFiles(directory, "*.json"))
LoadEntryFile(file);
}
return new List<string> { merged };
BuildSetIndex();
return Directory.GetFiles(directory, "*.json").ToList();
}
/// <summary>Ingests a single database file (merged or per-vehicle).</summary>
public void LoadFile(string file)
{
if (string.Equals(Path.GetFileName(file), "vehicles.json", StringComparison.OrdinalIgnoreCase))
LoadMerged(file);
else
LoadEntryFile(file);
}
private void LoadMerged(string file)
@@ -171,6 +200,12 @@ public class VehicleDatabase
foreach (var texture in entry.Textures)
{
// index every texture (wrecks too) so set / skin references resolve
if (!string.IsNullOrEmpty(texture.Uuid))
TextureByUuid[texture.Uuid!] = texture;
if (!string.IsNullOrEmpty(texture.Skinfile))
TextureBySkin.TryAdd(Path.GetFileNameWithoutExtension(texture.Skinfile).ToLowerInvariant(), texture);
// skip wrecks from the standard browser list
if (texture.Wreck) continue;
Textures.Add(texture);
@@ -179,6 +214,25 @@ public class VehicleDatabase
Sets.AddRange(entry.Sets);
}
/// <summary>
/// If the texture belongs to an automatic-consist set, returns all of that
/// set's textures in their defined order; otherwise null.
/// </summary>
public List<VehicleTexture>? ResolveSet(VehicleTexture texture)
{
if (string.IsNullOrEmpty(texture.Uuid)) return null;
if (!SetByTextureUuid.TryGetValue(texture.Uuid!, out var set) || set.TextureRefs is null)
return null;
var cars = new List<VehicleTexture>();
foreach (string uuid in set.TextureRefs)
{
if (!string.IsNullOrEmpty(uuid) && TextureByUuid.TryGetValue(uuid, out var tex))
cars.Add(tex);
}
return cars.Count > 0 ? cars : null;
}
private void BuildSetIndex()
{
foreach (var set in Sets)
@@ -192,14 +246,36 @@ public class VehicleDatabase
}
}
/// <summary>Best miniature name for a texture (texture_mini -> mini_ref -> group mini).</summary>
/// <summary>
/// Miniature name for a texture: the texture_mini property, but if no .bmp
/// for it exists, falls back to the mini of the group it belongs to.
/// </summary>
public string? ResolveMiniName(VehicleTexture texture)
{
if (!string.IsNullOrEmpty(texture.TextureMini)) return texture.TextureMini;
if (!string.IsNullOrEmpty(texture.MiniRef)) return texture.MiniRef;
if (texture.Group is not null && GroupsById.TryGetValue(texture.Group, out var grp))
if (!string.IsNullOrEmpty(texture.TextureMini) && MiniPath(texture.TextureMini) != null)
return texture.TextureMini;
if (texture.Group != null && GroupsById.TryGetValue(texture.Group, out var grp)
&& !string.IsNullOrEmpty(grp.Mini))
return grp.Mini;
return null;
return texture.TextureMini;
}
/// <summary>Resolved mini for a skin file (matched case-insensitively), or null.</summary>
public string? MiniForSkin(string? skinFile)
{
if (string.IsNullOrEmpty(skinFile)) return null;
string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant();
return TextureBySkin.TryGetValue(key, out var tex) ? ResolveMiniName(tex) : null;
}
/// <summary>The texture for a skin file (matched case-insensitively), or null.</summary>
public VehicleTexture? TextureForSkin(string? skinFile)
{
if (string.IsNullOrEmpty(skinFile)) return null;
string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant();
return TextureBySkin.TryGetValue(key, out var tex) ? tex : null;
}
/// <summary>Header label for the group a texture belongs to.</summary>
@@ -213,12 +289,30 @@ public class VehicleDatabase
return groupId ?? "";
}
/// <summary>Resolves a miniature .bmp path under textures/mini/, or null if missing.</summary>
// Case-insensitive index of mini .bmp files (built once).
private static Dictionary<string, string>? _miniIndex;
/// <summary>
/// Resolves a miniature .bmp path under textures/mini/ case-insensitively,
/// or null if missing. The directory is indexed once and reused.
/// </summary>
public static string? MiniPath(string? miniName, string miniDir = "textures/mini/")
{
if (string.IsNullOrEmpty(miniName)) return null;
string path = Path.Combine(miniDir, miniName + ".bmp");
return File.Exists(path) ? path : null;
var index = _miniIndex ??= BuildMiniIndex(miniDir);
return index.TryGetValue(miniName!.ToLowerInvariant(), out var path) ? path : null;
}
private static Dictionary<string, string> BuildMiniIndex(string miniDir)
{
var index = new Dictionary<string, string>();
if (!Directory.Exists(miniDir))
return index;
foreach (string file in Directory.GetFiles(miniDir, "*.bmp"))
index[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = file;
return index;
}
}

View File

@@ -0,0 +1,25 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:starterNg="clr-namespace:StarterNG"
x:Class="StarterNG.SplashWindow"
SystemDecorations="None"
CanResize="False"
ShowInTaskbar="True"
WindowStartupLocation="CenterScreen"
Width="520" Height="300"
Background="#15171B">
<Grid>
<Image Source="/Assets/Logo/splash.png" Stretch="UniformToFill" />
<Border VerticalAlignment="Bottom" Background="#CC0E0F12" Padding="18,12">
<StackPanel Spacing="8">
<TextBlock x:Name="statusText"
Foreground="#EEEEEE" FontSize="12"
TextTrimming="CharacterEllipsis"
Text="{Binding [LoadingVehicles], Source={x:Static starterNg:App.Loc}}" />
<ProgressBar x:Name="progressBar"
Minimum="0" Maximum="1" Value="0" Height="6" />
</StackPanel>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,30 @@
using Avalonia.Controls;
using StarterNG.Classes;
namespace StarterNG;
public partial class SplashWindow : Window
{
public SplashWindow()
{
InitializeComponent();
}
/// <summary>Updates the bar and status line. Must be called on the UI thread.</summary>
public void Report(LoadStatus status)
{
progressBar.Value = status.Fraction;
statusText.Text = Format(status);
}
private static string Format(LoadStatus s)
{
string detail = string.IsNullOrEmpty(s.Detail) ? "" : $": {s.Detail}";
return s.Phase switch
{
LoadPhase.Vehicles => App.Loc["LoadingVehicles"] + detail,
LoadPhase.Sceneries => App.Loc["LoadingSceneries"] + detail,
_ => App.Loc["LoadingDone"]
};
}
}

View File

@@ -41,6 +41,8 @@
<AvaloniaResource Include="Assets\Logo\maszyna_white.svg" />
<None Remove="Assets\Logo\Other\Discord.svg" />
<AvaloniaResource Include="Assets\Logo\Other\Discord.svg" />
<None Remove="Assets\Logo\splash.png" />
<AvaloniaResource Include="Assets\Logo\splash.png" />
<None Remove="Assets\Test\ep07p.bmp" />
<AvaloniaResource Include="Assets\Test\ep07p.bmp" />
</ItemGroup>

View File

@@ -4,6 +4,7 @@
xmlns:sukiUi="clr-namespace:SukiUI.Controls;assembly=SukiUI"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:starterNg="clr-namespace:StarterNG"
xmlns:views="clr-namespace:StarterNG.Views"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="700"
x:Class="StarterNG.Views.Depot">
<Grid Margin="16" ColumnSpacing="16" RowSpacing="16">
@@ -30,6 +31,8 @@
<TextBox Name="searchBox" DockPanel.Dock="Top" Margin="0,0,0,8"
Watermark="{Binding [Search], Source={x:Static starterNg:App.Loc}}"
TextChanged="SearchBox_OnTextChanged" />
<!-- Folded groups (built in code-behind); collapsed groups hold no
controls, so the list stays light and scrolls smoothly. -->
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel Name="browserStack" Spacing="2" />
</ScrollViewer>
@@ -47,6 +50,7 @@
<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}}"
DropDownOpened="SceneryConsistCombo_OnDropDownOpened"
SelectionChanged="SceneryConsistCombo_OnSelectionChanged" />
</Grid>
</sukiUi:GroupBox>

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,7 @@
<Button Grid.Column="0" Height="36" Classes="Flat"
VerticalAlignment="Bottom" Grid.Row="3" Grid.ColumnSpan="2"
Click="StartButton_OnClick"
Content="{Binding [Start], Source={x:Static starterNg:App.Loc}}">
</Button>

View File

@@ -1,8 +1,11 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using StarterNG.Classes;
@@ -16,19 +19,9 @@ public partial class Scenarios : UserControl
public Scenarios()
{
InitializeComponent();
sceneries = new List<Scenery>();
// load sceneries
List<string> scnFiles = Directory.GetFiles("scenery/", "*.scn").ToList();
foreach (string scnFile in scnFiles)
{
// skip temp sceneries
if (Path.GetFileName(scnFile).StartsWith("$"))
continue;
// Parse all files
sceneries.Add(new Scenery(scnFile));
}
// Sceneries are parsed once at startup (behind the splash).
sceneries = GameData.Instance.Sceneries;
var groupNodes = new Dictionary<string, TreeViewItem>();
@@ -109,15 +102,18 @@ public partial class Scenarios : UserControl
if (vItem?.Tag is not int vTag)
return;
Trainset selectedTrainset = selectedScn.Trainsets[vTag];
// share the selection so the depot edits this consist in place
AppState.Instance.CurrentScenery = selectedScn;
AppState.Instance.CurrentTrainset = selectedTrainset;
var db = GameData.Instance.Vehicles;
foreach (var train in selectedTrainset.Vehicles)
{
string path = Path.Combine(
"textures",
"mini",
train.SkinFile + ".bmp"
);
if (!File.Exists(path))
// thumbnail name comes from the matching texture's texture_mini
string miniName = db.MiniForSkin(train.SkinFile) ?? train.SkinFile;
string? path = VehicleDatabase.MiniPath(miniName);
if (path is null)
{
// fallback
continue;
@@ -135,4 +131,46 @@ public partial class Scenarios : UserControl
}
missionDescription.Text = selectedTrainset.Description;
}
// Exports the (possibly depot-modified) scenery to a $-prefixed copy and
// launches the game on it with the selected consist's driven vehicle.
private void StartButton_OnClick(object? sender, RoutedEventArgs e)
{
var scenery = AppState.Instance.CurrentScenery;
if (scenery is null)
return;
var trainset = AppState.Instance.CurrentTrainset;
// write scenery/$<name>.scn with the replaced trainsets
string dir = System.IO.Path.GetDirectoryName(scenery.Path) ?? "scenery";
string exportName = "$" + System.IO.Path.GetFileName(scenery.Path);
string exportPath = System.IO.Path.Combine(dir, exportName);
try
{
File.WriteAllText(exportPath, scenery.BuildExportContent(), Encoding.GetEncoding(1250));
}
catch
{
return; // couldn't write the scenery file
}
// the player's vehicle is the node name of the driven car in the consist
string? vehicle = trainset?.Vehicles
.FirstOrDefault(v => v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver)?.Name
?? trainset?.Vehicles.FirstOrDefault()?.Name;
// launch the game: -s $<name>.scn -v <vehicle>
try
{
Process.Start(new ProcessStartInfo(StarterNG.Classes.Settings.Instance.ExecutablePath)
{
Arguments = $"-s {exportName} -v {vehicle}",
UseShellExecute = true
});
}
catch
{
// executable not found / not configured yet
}
}
}