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;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Markup.Xaml.Styling; using Avalonia.Markup.Xaml.Styling;
using Avalonia.Threading;
using StarterNG.Classes;
using StarterNG.Services; using StarterNG.Services;
namespace StarterNG; namespace StarterNG;
@@ -54,7 +58,30 @@ public partial class App : Application
{ {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) 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(); base.OnFrameworkInitializationCompleted();

View File

@@ -33,11 +33,36 @@
<x:String x:Key="CatDMU">DMU</x:String> <x:String x:Key="CatDMU">DMU</x:String>
<x:String x:Key="CatCarriagesA">Carriages A</x:String> <x:String x:Key="CatCarriagesA">Carriages A</x:String>
<x:String x:Key="CatCarriagesB">Carriages B</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="Brakes">Brakes</x:String>
<x:String x:Key="Loads">Loads</x:String> <x:String x:Key="Loads">Loads</x:String>
<x:String x:Key="Coupling">Coupling</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="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="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 --> <!-- Settings -->
<x:String x:Key="General">General</x:String> <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="CatDMU">SZT</x:String>
<x:String x:Key="CatCarriagesA">Wagony A</x:String> <x:String x:Key="CatCarriagesA">Wagony A</x:String>
<x:String x:Key="CatCarriagesB">Wagony B</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="Brakes">Hamulce</x:String>
<x:String x:Key="Loads">Ładunki</x:String> <x:String x:Key="Loads">Ładunki</x:String>
<x:String x:Key="Coupling">Sprzęg</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="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="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 --> <!-- Ustawienia -->
<x:String x:Key="General">Ogólne</x:String> <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 List<Trainset> Trainsets;
public string Group; public string Group;
public string Path; 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) public Scenery(string path)
{ {
this.Path = path; this.Path = path;
@@ -19,7 +24,7 @@ public class Scenery
throw new FileNotFoundException(path); throw new FileNotFoundException(path);
var encoding = Encoding.GetEncoding(1250); // Windows-1250 var encoding = Encoding.GetEncoding(1250); // Windows-1250
string content = File.ReadAllText(path, encoding); string content = File.ReadAllText(path, encoding);
// property scanning // property scanning
var match = Regex.Match( var match = Regex.Match(
content, content,
@@ -27,9 +32,9 @@ public class Scenery
RegexOptions.Multiline RegexOptions.Multiline
); );
this.Group = match.Success ? match.Groups[1].Value : null; this.Group = match.Success ? match.Groups[1].Value : null;
// parsing trainsets // parsing trainsets
List<string> trainsetEntries = new List<string>(); List<string> trainsetEntries = new List<string>();
Regex regex = new Regex( Regex regex = new Regex(
@"trainset\b[\s\S]*?\bendtrainset\b", @"trainset\b[\s\S]*?\bendtrainset\b",
@@ -41,18 +46,23 @@ public class Scenery
trainsetEntries.Add(match.Value); trainsetEntries.Add(match.Value);
return $"{{{{{idx++}}}}}"; 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) foreach (string trainsetEntry in trainsetEntries)
{ Trainsets.Add(new Trainset(trainsetEntry));
try }
{
Trainsets.Add(new Trainset(trainsetEntry)); /// <summary>
} /// Rebuilds the full .scn content with the (possibly modified) trainsets
catch /// substituted back into their original positions.
{ /// </summary>
// skip malformed trainset entries so one bad scenery public string BuildExportContent()
// doesn't break loading the rest {
} 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 float Velocity;
public string Description; public string Description;
public List<Dynamic> Vehicles; 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) public Trainset(string trainsetEntry)
{ {
RawEntry = trainsetEntry;
Vehicles = new List<Dynamic>(); Vehicles = new List<Dynamic>();
Description = ""; Description = "";
try
{
ParseEntry(trainsetEntry);
Parsed = true;
}
catch
{
Parsed = false;
}
}
private void ParseEntry(string trainsetEntry)
{
List<string> tokens = Regex List<string> tokens = Regex
.Matches(trainsetEntry, @"/\*[\s\S]*?\*/|//[^\r\n]*|[^\s\r\n]+") .Matches(trainsetEntry, @"/\*[\s\S]*?\*/|//[^\r\n]*|[^\s\r\n]+")
.Select(m => m.Value) .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() public string GetTrainsetEntry()
{ {
string entry = ""; string entry = "";
@@ -120,8 +147,8 @@ public class Trainset
entry += "trainset "; entry += "trainset ";
entry += this.Name + " "; entry += this.Name + " ";
entry += this.Track + " "; entry += this.Track + " ";
entry += this.Offset + " "; entry += this.Offset.ToString(CultureInfo.InvariantCulture) + " ";
entry += this.Velocity + " "; entry += this.Velocity.ToString(CultureInfo.InvariantCulture) + " ";
entry += "\n"; entry += "\n";
foreach (Dynamic vehicle in Vehicles) foreach (Dynamic vehicle in Vehicles)
{ {

View File

@@ -94,6 +94,12 @@ public class VehicleDatabase
/// <summary>Texture-uuid -> automatic-consist set that contains it.</summary> /// <summary>Texture-uuid -> automatic-consist set that contains it.</summary>
public Dictionary<string, VehicleSet> SetByTextureUuid { get; } = new(); 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() private static readonly JsonSerializerOptions JsonOpts = new()
{ {
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
@@ -102,32 +108,55 @@ public class VehicleDatabase
}; };
/// <summary> /// <summary>
/// Loads the database. When a merged vehicles.json is present it is used, /// Loads the database in one call. When a merged vehicles.json is present
/// otherwise every per-vehicle *.json file in the directory is read. /// it is used, otherwise every per-vehicle *.json file is read.
/// Never throws: unreadable files are skipped. /// Never throws: unreadable files are skipped.
/// </summary> /// </summary>
public void Load(string directory = "databases/vehicles/") 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(); Textures.Clear();
GroupsById.Clear(); GroupsById.Clear();
Sets.Clear(); Sets.Clear();
SetByTextureUuid.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)) if (!Directory.Exists(directory))
return; return new List<string>();
string merged = Path.Combine(directory, "vehicles.json"); string merged = Path.Combine(directory, "vehicles.json");
if (File.Exists(merged)) if (File.Exists(merged))
{ return new List<string> { merged };
LoadMerged(merged);
}
else
{
foreach (string file in Directory.GetFiles(directory, "*.json"))
LoadEntryFile(file);
}
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) private void LoadMerged(string file)
@@ -171,6 +200,12 @@ public class VehicleDatabase
foreach (var texture in entry.Textures) 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 // skip wrecks from the standard browser list
if (texture.Wreck) continue; if (texture.Wreck) continue;
Textures.Add(texture); Textures.Add(texture);
@@ -179,6 +214,25 @@ public class VehicleDatabase
Sets.AddRange(entry.Sets); 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() private void BuildSetIndex()
{ {
foreach (var set in Sets) 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) public string? ResolveMiniName(VehicleTexture texture)
{ {
if (!string.IsNullOrEmpty(texture.TextureMini)) return texture.TextureMini; if (!string.IsNullOrEmpty(texture.TextureMini) && MiniPath(texture.TextureMini) != null)
if (!string.IsNullOrEmpty(texture.MiniRef)) return texture.MiniRef; return texture.TextureMini;
if (texture.Group is not null && GroupsById.TryGetValue(texture.Group, out var grp))
if (texture.Group != null && GroupsById.TryGetValue(texture.Group, out var grp)
&& !string.IsNullOrEmpty(grp.Mini))
return 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> /// <summary>Header label for the group a texture belongs to.</summary>
@@ -213,12 +289,30 @@ public class VehicleDatabase
return groupId ?? ""; 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/") public static string? MiniPath(string? miniName, string miniDir = "textures/mini/")
{ {
if (string.IsNullOrEmpty(miniName)) return null; 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" /> <AvaloniaResource Include="Assets\Logo\maszyna_white.svg" />
<None Remove="Assets\Logo\Other\Discord.svg" /> <None Remove="Assets\Logo\Other\Discord.svg" />
<AvaloniaResource Include="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" /> <None Remove="Assets\Test\ep07p.bmp" />
<AvaloniaResource Include="Assets\Test\ep07p.bmp" /> <AvaloniaResource Include="Assets\Test\ep07p.bmp" />
</ItemGroup> </ItemGroup>

View File

@@ -4,6 +4,7 @@
xmlns:sukiUi="clr-namespace:SukiUI.Controls;assembly=SukiUI" xmlns:sukiUi="clr-namespace:SukiUI.Controls;assembly=SukiUI"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:starterNg="clr-namespace:StarterNG" xmlns:starterNg="clr-namespace:StarterNG"
xmlns:views="clr-namespace:StarterNG.Views"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="700" mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="700"
x:Class="StarterNG.Views.Depot"> x:Class="StarterNG.Views.Depot">
<Grid Margin="16" ColumnSpacing="16" RowSpacing="16"> <Grid Margin="16" ColumnSpacing="16" RowSpacing="16">
@@ -30,6 +31,8 @@
<TextBox Name="searchBox" DockPanel.Dock="Top" Margin="0,0,0,8" <TextBox Name="searchBox" DockPanel.Dock="Top" Margin="0,0,0,8"
Watermark="{Binding [Search], Source={x:Static starterNg:App.Loc}}" Watermark="{Binding [Search], Source={x:Static starterNg:App.Loc}}"
TextChanged="SearchBox_OnTextChanged" /> 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"> <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel Name="browserStack" Spacing="2" /> <StackPanel Name="browserStack" Spacing="2" />
</ScrollViewer> </ScrollViewer>
@@ -47,6 +50,7 @@
<ComboBox Name="sceneryConsistCombo" Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" <ComboBox Name="sceneryConsistCombo" Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2"
HorizontalAlignment="Stretch" MaxDropDownHeight="420" HorizontalAlignment="Stretch" MaxDropDownHeight="420"
PlaceholderText="{Binding [LoadFromScenery], Source={x:Static starterNg:App.Loc}}" PlaceholderText="{Binding [LoadFromScenery], Source={x:Static starterNg:App.Loc}}"
DropDownOpened="SceneryConsistCombo_OnDropDownOpened"
SelectionChanged="SceneryConsistCombo_OnSelectionChanged" /> SelectionChanged="SceneryConsistCombo_OnSelectionChanged" />
</Grid> </Grid>
</sukiUi:GroupBox> </sukiUi:GroupBox>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,8 +1,11 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using StarterNG.Classes; using StarterNG.Classes;
@@ -16,20 +19,10 @@ public partial class Scenarios : UserControl
public Scenarios() public Scenarios()
{ {
InitializeComponent(); InitializeComponent();
sceneries = new List<Scenery>();
// Sceneries are parsed once at startup (behind the splash).
// load sceneries sceneries = GameData.Instance.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));
}
var groupNodes = new Dictionary<string, TreeViewItem>(); var groupNodes = new Dictionary<string, TreeViewItem>();
for (int i = 0; i < sceneries.Count; i++) for (int i = 0; i < sceneries.Count; i++)
@@ -109,20 +102,23 @@ public partial class Scenarios : UserControl
if (vItem?.Tag is not int vTag) if (vItem?.Tag is not int vTag)
return; return;
Trainset selectedTrainset = selectedScn.Trainsets[vTag]; 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) foreach (var train in selectedTrainset.Vehicles)
{ {
string path = Path.Combine( // thumbnail name comes from the matching texture's texture_mini
"textures", string miniName = db.MiniForSkin(train.SkinFile) ?? train.SkinFile;
"mini", string? path = VehicleDatabase.MiniPath(miniName);
train.SkinFile + ".bmp" if (path is null)
);
if (!File.Exists(path))
{ {
// fallback // fallback
continue; continue;
} }
var bitmap = new Bitmap(path); var bitmap = new Bitmap(path);
var image = new Image var image = new Image
{ {
@@ -135,4 +131,46 @@ public partial class Scenarios : UserControl
} }
missionDescription.Text = selectedTrainset.Description; 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
}
}
} }