diff --git a/StarterNG/Views/Scenarios.axaml b/StarterNG/Views/Scenarios.axaml
index 615c94e..7384d7f 100644
--- a/StarterNG/Views/Scenarios.axaml
+++ b/StarterNG/Views/Scenarios.axaml
@@ -14,7 +14,7 @@
-
+
@@ -27,6 +27,14 @@
IsCheckedChanged="ArchivalSwitch_OnChanged"
OnContent="{Binding [ShowArchival], Source={x:Static starterNg:App.Loc}}"
OffContent="{Binding [ShowArchival], Source={x:Static starterNg:App.Loc}}" />
+
+
+
+
@@ -85,6 +93,10 @@
VerticalAlignment="Center" Padding="10,4"
Click="WeatherTimeNow_OnClick"
Content="{Binding [CurrentTime], Source={x:Static starterNg:App.Loc}}" />
+
-
@@ -150,6 +162,13 @@
FontSize="12" />
+
+
+
+
+
@@ -169,18 +188,26 @@
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
diff --git a/StarterNG/Views/Scenarios.axaml.cs b/StarterNG/Views/Scenarios.axaml.cs
index 1d18627..dfe661c 100644
--- a/StarterNG/Views/Scenarios.axaml.cs
+++ b/StarterNG/Views/Scenarios.axaml.cs
@@ -9,9 +9,11 @@ using System.Text.RegularExpressions;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
+using Avalonia.Layout;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using StarterNG.Classes;
+using StarterNG.Domain;
namespace StarterNG.Views;
@@ -46,6 +48,7 @@ public partial class Scenarios : UserControl
_loadingFilters = false;
BuildSceneryTree();
+ RestoreLastScenery();
// refresh the consist preview when the view is shown again (e.g. after
// editing the consist in the depot). Switching tabs only toggles IsVisible
@@ -86,11 +89,15 @@ public partial class Scenarios : UserControl
if (scenery.Archival && !showArchival)
continue;
+ bool expand = StarterNG.Classes.Settings.Instance.AutoExpandSceneryTree;
+ SceneryI18n.LoadFor(scenery, App.Loc.CurrentLangCode);
+ string label = scenery.DisplayName;
+
if (string.IsNullOrEmpty(scenery.Group))
{
topLevel.Add(new TreeViewItem
{
- Header = Path.GetFileNameWithoutExtension(scenery.Path),
+ Header = label,
Tag = i
});
continue;
@@ -98,14 +105,18 @@ public partial class Scenarios : UserControl
if (!groupNodes.TryGetValue(scenery.Group, out var groupNode))
{
- groupNode = new TreeViewItem { Header = scenery.Group, IsExpanded = false };
+ groupNode = new TreeViewItem
+ {
+ Header = SceneryI18n.T(scenery.Group),
+ IsExpanded = expand
+ };
groupNodes[scenery.Group] = groupNode;
topLevel.Add(groupNode);
}
groupNode.Items.Add(new TreeViewItem
{
- Header = Path.GetFileNameWithoutExtension(scenery.Path),
+ Header = label,
Tag = i
});
}
@@ -124,9 +135,102 @@ public partial class Scenarios : UserControl
sceneryList.Items.Add(item);
}
+ // Restores the scenery selected last session (starter.last.scenery).
+ // Like Pascal ScenariosList: fall back to the first leaf when nothing matches.
+ private void RestoreLastScenery()
+ {
+ string last = StarterNG.Classes.Settings.Instance.LastScenery;
+ string want = Path.GetFileNameWithoutExtension(last);
+
+ TreeViewItem? MatchLeaf(TreeViewItem node)
+ {
+ if (node.Tag is not int idx || idx < 0 || idx >= Sceneries.Count)
+ return null;
+ var scn = Sceneries[idx];
+ if (string.IsNullOrWhiteSpace(want)) return null;
+ if (string.Equals(scn.DisplayName, want, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(Path.GetFileNameWithoutExtension(scn.Path), want, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(node.Header as string, want, StringComparison.OrdinalIgnoreCase))
+ return node;
+ return null;
+ }
+
+ TreeViewItem? firstLeaf = null;
+ foreach (var obj in sceneryList.Items)
+ {
+ if (obj is not TreeViewItem node)
+ continue;
+
+ if (node.Tag is int)
+ {
+ firstLeaf ??= node;
+ if (MatchLeaf(node) is { } hit)
+ {
+ ExpandAncestors(hit);
+ sceneryList.SelectedItem = hit;
+ return;
+ }
+ }
+
+ foreach (var childObj in node.Items)
+ {
+ if (childObj is not TreeViewItem { Tag: int } child)
+ continue;
+ firstLeaf ??= child;
+ if (MatchLeaf(child) is { } hit)
+ {
+ node.IsExpanded = true;
+ sceneryList.SelectedItem = hit;
+ return;
+ }
+ }
+ }
+
+ if (firstLeaf != null)
+ {
+ if (firstLeaf.Parent is TreeViewItem parent)
+ parent.IsExpanded = true;
+ sceneryList.SelectedItem = firstLeaf;
+ }
+ }
+
+ private static void ExpandAncestors(TreeViewItem node)
+ {
+ var parent = node.Parent;
+ while (parent != null)
+ {
+ if (parent is TreeViewItem tvi)
+ tvi.IsExpanded = true;
+ parent = parent.Parent;
+ }
+ }
+
+ private void PersistLastScenery(Scenery scenery)
+ {
+ // Pascal InitSCN stores the tree node text (//$n or file name).
+ string name = scenery.DisplayName;
+ if (string.Equals(StarterNG.Classes.Settings.Instance.LastScenery, name, StringComparison.OrdinalIgnoreCase))
+ return;
+ StarterNG.Classes.Settings.Instance.LastScenery = name;
+ StarterNG.Classes.Settings.Instance.Save();
+ }
+
+ private static string TrainsetListLabel(Trainset trainset) =>
+ TrainsetDisplay.CompositionText(trainset)
+ ?? string.Format(App.Loc["EmptyTrainset"], trainset.Track ?? "");
+
+ private static Control TrainsetListContent(Trainset trainset) =>
+ new Avalonia.Controls.TextBlock
+ {
+ Text = TrainsetListLabel(trainset),
+ TextTrimming = Avalonia.Media.TextTrimming.CharacterEllipsis,
+ TextWrapping = Avalonia.Media.TextWrapping.NoWrap
+ };
+
// Fills the Vehicles list for a scenery, honouring the two filters:
// - "AI vehicles" off hides consists whose //$o description starts with '-';
// - "Drivable only" on hides decoration consists (the //$decor flag).
+ // Empty trainsets stay visible (label shows track), matching the Pascal starter.
private void PopulateVehicleList(Scenery scn)
{
vehicleList.Items.Clear();
@@ -137,15 +241,13 @@ public partial class Scenarios : UserControl
for (int i = 0; i < scn.Trainsets.Count; i++)
{
var trainset = scn.Trainsets[i];
- string trainsetName = string.Join(" + ", trainset.Vehicles.Select(dyn => dyn.Name));
- if (string.IsNullOrWhiteSpace(trainsetName)) continue; // skip empty
if (IsAiTrainset(trainset) && !showAi) continue;
if (trainset.Decor && drivableOnly) continue;
var listItem = new ListBoxItem
{
- Content = trainsetName,
+ Content = TrainsetListContent(trainset),
Tag = i
};
// right-click: save/load to the warehouse + clipboard copy/paste
@@ -188,6 +290,7 @@ public partial class Scenarios : UserControl
StarterNG.Classes.Settings.Instance.Save();
BuildSceneryTree();
+ RestoreLastScenery();
}
// Replaces a consist's vehicles (from a loaded warehouse preset or a pasted
@@ -195,7 +298,7 @@ public partial class Scenarios : UserControl
private void ApplyConsist(ListBoxItem item, Trainset trainset, List vehicles)
{
trainset.Vehicles = vehicles;
- item.Content = string.Join(" + ", trainset.Vehicles.Select(d => d.Name));
+ item.Content = TrainsetListContent(trainset);
if (ReferenceEquals(AppState.Instance.CurrentTrainset, trainset))
ShowConsist(trainset);
}
@@ -212,7 +315,7 @@ public partial class Scenarios : UserControl
foreach (var obj in vehicleList.Items)
{
if (obj is ListBoxItem { Tag: int vi } lbi && vi < scn.Trainsets.Count)
- lbi.Content = string.Join(" + ", scn.Trainsets[vi].Vehicles.Select(d => d.Name));
+ lbi.Content = TrainsetListContent(scn.Trainsets[vi]);
}
}
@@ -238,6 +341,7 @@ public partial class Scenarios : UserControl
// scenery-level info (distinct from the per-consist scenario description)
ShowSceneryInfo(selectedScn);
ShowWeather(selectedScn);
+ PersistLastScenery(selectedScn);
// no consist chosen yet for this scenery
missionDescription.Text = "";
@@ -250,7 +354,8 @@ public partial class Scenarios : UserControl
// Shows the selected scenery's description (//$d) and 1:1 image (//$i).
private void ShowSceneryInfo(Scenery scenery)
{
- sceneryDescription.Text = scenery.Description;
+ SceneryI18n.LoadFor(scenery, App.Loc.CurrentLangCode);
+ sceneryDescription.Text = scenery.LocalizedDescription;
string? imagePath = scenery.ImagePath;
if (imagePath is not null)
@@ -261,13 +366,154 @@ public partial class Scenarios : UserControl
}
catch
{
- sceneryImage.Source = null; // unreadable / unsupported image
+ sceneryImage.Source = null;
}
}
else
{
sceneryImage.Source = null;
}
+
+ PopulateAttachments(scenery);
+ PopulateIncludes(scenery);
+ PopulateFaults(scenery);
+ }
+
+ private void PopulateFaults(Scenery scenery)
+ {
+ var lines = new List();
+ lines.AddRange(scenery.Faults);
+
+ var db = GameData.Instance.Vehicles;
+ foreach (var ts in scenery.Trainsets)
+ {
+ if (!ts.Parsed)
+ lines.Add($"# unparsed trainset: {ts.Name}");
+ foreach (var v in ts.Vehicles)
+ {
+ if (string.IsNullOrEmpty(v.SkinFile)) continue;
+ if (db.TextureForSkin(v.SkinFile) is null)
+ lines.Add($"# unknown texture: {v.Name} ({v.SkinFile})");
+ }
+ }
+
+ foreach (var v in scenery.LooseVehicles)
+ {
+ if (db.TextureForSkin(v.SkinFile) is null)
+ lines.Add($"# unknown loose texture: {v.Name} ({v.SkinFile})");
+ }
+
+ faultListBox.Text = string.Join(Environment.NewLine, lines);
+ infoTab.IsVisible = lines.Count > 0 || scenery.Includes.Any(i => i.Kind != 2);
+ }
+
+ private void PopulateAttachments(Scenery scenery)
+ {
+ attachmentsPanel.Children.Clear();
+ foreach (var att in scenery.Attachments)
+ {
+ var btn = new Button
+ {
+ Content = att.Label,
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ HorizontalContentAlignment = HorizontalAlignment.Left,
+ Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand),
+ Tag = att.FilePath
+ };
+ btn.Classes.Add("Flat");
+ ToolTip.SetTip(btn, att.FilePath);
+ btn.Click += (_, _) => OpenAttachment(att.FilePath);
+ attachmentsPanel.Children.Add(btn);
+ }
+ }
+
+ private void PopulateIncludes(Scenery scenery)
+ {
+ includesPanel.Children.Clear();
+ var opts = scenery.Includes.Where(i => i.Kind != 2).ToList();
+ if (opts.Count == 0) return;
+
+ includesPanel.Children.Add(new TextBlock
+ {
+ Text = App.Loc["OptionalIncludes"],
+ FontWeight = Avalonia.Media.FontWeight.Bold,
+ FontSize = 12,
+ Margin = new Thickness(0, 0, 0, 2)
+ });
+
+ foreach (var inc in opts)
+ {
+ var check = new CheckBox
+ {
+ Content = $"{inc.Desc} ({inc.FilePath})",
+ IsChecked = inc.Selected,
+ FontSize = 11,
+ Tag = inc
+ };
+ check.IsCheckedChanged += (_, _) =>
+ {
+ if (check.Tag is SceneryInclude s)
+ s.Selected = check.IsChecked == true;
+ };
+ includesPanel.Children.Add(check);
+ }
+ }
+
+ private static void OpenAttachment(string relativePath)
+ {
+ string full = Path.GetFullPath(relativePath);
+ if (!File.Exists(full))
+ full = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), relativePath));
+ if (!File.Exists(full))
+ return;
+ try
+ {
+ Process.Start(new ProcessStartInfo(full) { UseShellExecute = true });
+ }
+ catch { /* no handler */ }
+ }
+
+ /// Rebuild tree labels after UI / scenery i18n language change.
+ public void RebuildAfterLanguageChange()
+ {
+ string? selected = AppState.Instance.CurrentScenery?.Path;
+ BuildSceneryTree();
+ if (!string.IsNullOrEmpty(selected))
+ {
+ StarterNG.Classes.Settings.Instance.LastScenery =
+ Path.GetFileNameWithoutExtension(selected);
+ RestoreLastScenery();
+ }
+ if (AppState.Instance.CurrentScenery != null)
+ ShowSceneryInfo(AppState.Instance.CurrentScenery);
+ }
+
+ private void ReloadSceneries_OnClick(object? sender, RoutedEventArgs e)
+ {
+ string? selected = AppState.Instance.CurrentScenery?.Path;
+ GameData.Instance.ReloadSceneries();
+ Sceneries = GameData.Instance.Sceneries;
+ BuildSceneryTree();
+ if (!string.IsNullOrEmpty(selected))
+ {
+ StarterNG.Classes.Settings.Instance.LastScenery =
+ Path.GetFileNameWithoutExtension(selected);
+ }
+ RestoreLastScenery();
+ }
+
+ private void OpenSceneryDir_OnClick(object? sender, RoutedEventArgs e)
+ {
+ var scn = AppState.Instance.CurrentScenery;
+ string dir = scn != null
+ ? (Path.GetDirectoryName(Path.GetFullPath(scn.Path)) ?? "scenery")
+ : Path.GetFullPath("scenery");
+ if (!Directory.Exists(dir)) return;
+ try
+ {
+ Process.Start(new ProcessStartInfo(dir) { UseShellExecute = true });
+ }
+ catch { }
}
private void VehicleList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
@@ -287,6 +533,7 @@ public partial class Scenarios : UserControl
// share the selection so the depot edits this consist in place
AppState.Instance.CurrentScenery = selectedScn;
AppState.Instance.CurrentTrainset = selectedTrainset;
+ AppState.Instance.StartingVehicleName = TrainsetDisplay.DefaultStartingVehicle(selectedTrainset);
ShowConsist(selectedTrainset);
ShowTimetable(selectedScn, selectedTrainset);
@@ -302,7 +549,7 @@ public partial class Scenarios : UserControl
try
{
weatherForm.IsEnabled = true;
- weatherTime.SelectedTime = ParseTime(scenery.WeatherTime);
+ weatherTime.SelectedTime = ParseTime(scenery.ScenarioTimeOverride);
weatherDay.Value = scenery.Day;
weatherTemp.Value = scenery.Temperature;
weatherFog.Value = System.Math.Clamp(scenery.FogEnd, 0, 10000);
@@ -326,7 +573,7 @@ public partial class Scenarios : UserControl
var scn = _currentScenery;
if (weatherTime.SelectedTime is { } t)
- scn.WeatherTime = $"{t.Hours:D2}:{t.Minutes:D2}";
+ scn.ScenarioTimeOverride = $"{t.Hours:D2}:{t.Minutes:D2}";
// In "Today" mode the day field is only a preview, so keep the stored day
// at 0 (the scenery then follows the live date).
scn.Day = _todaySeason ? 0 : (int)(weatherDay.Value ?? 0);
@@ -368,6 +615,14 @@ public partial class Scenarios : UserControl
weatherTime.SelectedTime = new TimeSpan(now.Hour, now.Minute, 0);
}
+ // Pascal actRestoreWeather — reload weather fields from the SCN snapshot.
+ private void WeatherRestore_OnClick(object? sender, RoutedEventArgs e)
+ {
+ if (_currentScenery is null) return;
+ _currentScenery.RestoreWeather();
+ ShowWeather(_currentScenery);
+ }
+
// Parses a stored "hh:mm" string into a TimeSpan, or null when it is missing
// or malformed (so the picker simply shows no selection).
private static TimeSpan? ParseTime(string? text)
@@ -484,15 +739,38 @@ public partial class Scenarios : UserControl
if (path is null)
continue; // fallback: no mini
+ int thumbH = StarterNG.Classes.Settings.Instance.LargeThumbnails ? 64 : 32;
consistStack.Children.Add(new Image
{
Source = new Bitmap(path),
- Height = 32,
+ Height = thumbH,
Stretch = Avalonia.Media.Stretch.Uniform,
Margin = new Thickness(0)
});
}
- missionDescription.Text = trainset.Description;
+ string desc = trainset.Description ?? "";
+ if (desc.TrimStart().StartsWith('-'))
+ desc = desc.TrimStart()[1..].TrimStart();
+ missionDescription.Text = SceneryI18n.T(desc);
+ UpdateTrainStats(trainset);
+ }
+
+ private void UpdateTrainStats(Trainset? trainset)
+ {
+ if (trainset is null)
+ {
+ trainStats.Text = "";
+ return;
+ }
+
+ var db = GameData.Instance.Vehicles;
+ trainStats.Text = TrainsetDisplay.FormatStats(
+ trainset,
+ car => Physics.For(car.DataFolder, db.TextureForSkin(car.SkinFile)?.Model)
+ ?? Physics.For(car.DataFolder, car.MmdFile)
+ ?? Physics.For(car.DataFolder, car.SkinFile),
+ App.Loc["Length"], App.Loc["Mass"], App.Loc["VehicleCount"], App.Loc["Track"],
+ car => db.TextureForSkin(car.SkinFile)?.ResolvedCategory);
}
// When returning to this view, refresh the preview of the selected consist