Show consist stats, keep empty trainsets and restore last scenery in Scenarios

#20 length/mass/vehicle/track status bar; #17 empty trainsets stay listed; #19 remember last scenery; #16 default starting vehicle; scenery i18n labels, attachments/includes/info tab, weather restore and reload.
This commit is contained in:
maj00r
2026-08-22 23:15:35 +02:00
parent ce806130f7
commit 12b06a2689
2 changed files with 327 additions and 22 deletions

View File

@@ -14,7 +14,7 @@
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition Height="78" />
<RowDefinition Height="150" />
</Grid.RowDefinitions>
<!-- Scenery tree (left) -->
@@ -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}}" />
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="8" Margin="2,4,0,0">
<Button Classes="Flat" FontSize="11" Padding="8,2" Cursor="Hand"
Click="ReloadSceneries_OnClick"
Content="{Binding [ReloadSceneries], Source={x:Static starterNg:App.Loc}}" />
<Button Classes="Flat" FontSize="11" Padding="8,2" Cursor="Hand"
Click="OpenSceneryDir_OnClick"
Content="{Binding [OpenSceneryFolder], Source={x:Static starterNg:App.Loc}}" />
</StackPanel>
<TreeView Name="sceneryList" Classes="Compact"
SelectionChanged="SceneryList_OnSelectionChanged" />
</DockPanel>
@@ -85,6 +93,10 @@
VerticalAlignment="Center" Padding="10,4"
Click="WeatherTimeNow_OnClick"
Content="{Binding [CurrentTime], Source={x:Static starterNg:App.Loc}}" />
<Button Name="weatherRestore" Classes="Flat" Cursor="Hand"
VerticalAlignment="Center" Padding="10,4"
Click="WeatherRestore_OnClick"
Content="{Binding [RestoreWeather], Source={x:Static starterNg:App.Loc}}" />
</StackPanel>
<TextBlock Grid.Row="1" Grid.Column="0" VerticalAlignment="Center"
@@ -109,7 +121,7 @@
<TextBlock Grid.Row="3" Grid.Column="0" VerticalAlignment="Center"
Text="{Binding [Temperature], Source={x:Static starterNg:App.Loc}}" />
<StackPanel Grid.Row="3" Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Slider Name="weatherTemp" Minimum="-20" Maximum="40" Width="220"
<Slider Name="weatherTemp" Minimum="-50" Maximum="50" Width="220"
TickFrequency="1" IsSnapToTickEnabled="True"
ValueChanged="Weather_OnSliderChanged" />
<TextBlock Name="weatherTempLabel" VerticalAlignment="Center" MinWidth="48" />
@@ -150,6 +162,13 @@
FontSize="12" />
</ScrollViewer>
</TabItem>
<TabItem Name="infoTab" Header="{Binding [TabInfo], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<TextBox Name="faultListBox" Margin="4" IsReadOnly="True"
AcceptsReturn="True" TextWrapping="Wrap"
FontFamily="Consolas, monospace" FontSize="12" />
</ScrollViewer>
</TabItem>
</TabControl>
</Grid>
@@ -169,18 +188,26 @@
<HeaderedContentControl Grid.Row="1" Theme="{StaticResource GroupBox}"
Header="{Binding [SceneryDescription], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<TextBlock TextWrapping="Wrap" Name="sceneryDescription" />
</ScrollViewer>
<DockPanel LastChildFill="True">
<StackPanel Name="attachmentsPanel" DockPanel.Dock="Bottom" Spacing="2" Margin="0,6,0,0" />
<StackPanel Name="includesPanel" DockPanel.Dock="Bottom" Spacing="2" Margin="0,6,0,0" />
<ScrollViewer VerticalScrollBarVisibility="Auto">
<TextBlock TextWrapping="Wrap" Name="sceneryDescription" />
</ScrollViewer>
</DockPanel>
</HeaderedContentControl>
</Grid>
<!-- Consist preview (full-width bottom, horizontally scrollable) -->
<HeaderedContentControl Grid.Row="1" Grid.ColumnSpan="3" Theme="{StaticResource GroupBox}"
Header="{Binding [Consist], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Name="consistStack" />
</ScrollViewer>
<DockPanel LastChildFill="True">
<TextBlock Name="trainStats" DockPanel.Dock="Top" FontSize="12"
Opacity="0.85" Margin="0,0,0,6" TextTrimming="CharacterEllipsis" />
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Name="consistStack" />
</ScrollViewer>
</DockPanel>
</HeaderedContentControl>
</Grid>
</UserControl>

View File

@@ -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<Dynamic> 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<string>();
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 */ }
}
/// <summary>Rebuild tree labels after UI / scenery i18n language change.</summary>
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