Load sceneries in parallel and skip the splash on a fast start

Scenery parsing runs on Parallel.ForEach and the reload button does it off
the UI thread. The splash window only appears when loading passes 300ms, so
a warm start goes straight to the main window. The log button moves into the
navigation row, dropping the separate caption strip; timetable and info use
the shared MonoFont family instead of a hard-coded Consolas.
This commit is contained in:
maj00r
2026-08-25 20:58:15 +02:00
parent a15044233e
commit a93af4bd44
7 changed files with 143 additions and 59 deletions

View File

@@ -75,25 +75,35 @@ public partial class App : Application
KeyboardConfig.Instance.Save();
};
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);
SplashWindow? splash = null;
var sw = System.Diagnostics.Stopwatch.StartNew();
Task.Run(async () =>
{
var progress = new Progress<LoadStatus>(status =>
{
Dispatcher.UIThread.Post(() =>
{
// Show splash only if loading takes more than 300ms
if (sw.ElapsedMilliseconds > 300 && splash == null)
{
splash = new SplashWindow();
desktop.MainWindow = splash;
splash.Show();
}
splash?.Report(status);
});
});
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
desktop.MainWindow = main;
main.Show();
splash.Close();
splash?.Close();
});
});
}

View File

@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace StarterNG.Classes;
@@ -66,15 +67,26 @@ public sealed class GameData
// (length / mass / couplings) resolve without a UI-thread directory scan.
Physics.PreloadIndex();
// 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++;
}
// Phase 2 - sceneries (parallelized)
var sceneryLock = new object();
var doneCount = done;
Parallel.ForEach(scnFiles, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
file =>
{
try
{
var scenery = new Scenery(file);
lock (sceneryLock)
{
Sceneries.Add(scenery);
doneCount++;
progress?.Report(new LoadStatus((double)doneCount / total, LoadPhase.Sceneries,
Path.GetFileName(file)));
}
}
catch { /* skip unreadable scenery */ }
});
done = doneCount;
progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null));
Loaded = true;
@@ -84,11 +96,19 @@ public sealed class GameData
public void ReloadSceneries(string sceneryDir = "scenery/")
{
Sceneries.Clear();
foreach (string file in EnumerateScenery(sceneryDir))
{
try { Sceneries.Add(new Scenery(file)); }
catch { /* skip */ }
}
var scnFiles = EnumerateScenery(sceneryDir);
var sceneryLock = new object();
Parallel.ForEach(scnFiles, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
file =>
{
try
{
var scenery = new Scenery(file);
lock (sceneryLock)
Sceneries.Add(scenery);
}
catch { /* skip */ }
});
}
private static List<string> EnumerateScenery(string sceneryDir)

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Media;
namespace StarterNG.Infrastructure;
/// <summary>
/// A fixed-width font family that actually exists on this machine.
/// Avalonia treats the extra names in "Consolas, monospace" as glyph fallbacks,
/// not as family alternatives, and "monospace" is a fontconfig alias it never
/// resolves — so that stack silently renders in the proportional UI font
/// everywhere except Windows. The timetable and the fault/preview boxes align
/// their columns with spaces, so they need a real monospace family: probe the
/// installed ones once and keep the first match.
/// </summary>
public static class MonoFont
{
private static readonly string[] Preferred =
{
"Consolas", // Windows
"Menlo", // macOS
"SF Mono",
"DejaVu Sans Mono", // most Linux distros
"Liberation Mono",
"Noto Sans Mono",
"Ubuntu Mono",
"Courier New"
};
public static FontFamily Family { get; } = Resolve();
private static FontFamily Resolve()
{
try
{
var installed = new HashSet<string>(
FontManager.Current.SystemFonts.Select(f => f.Name),
StringComparer.OrdinalIgnoreCase);
foreach (string name in Preferred)
if (installed.Contains(name))
return new FontFamily(name);
// None of the usual suspects: take any family that calls itself Mono.
string? anyMono = installed.FirstOrDefault(
n => n.Contains("Mono", StringComparison.OrdinalIgnoreCase));
if (anyMono is not null)
return new FontFamily(anyMono);
}
catch { /* no font manager yet - fall back to the default family */ }
return FontFamily.Default;
}
}

View File

@@ -28,29 +28,13 @@
Background="{StaticResource CarbonPanelBg}"
BorderBrush="{StaticResource CarbonBorderLight}"
BorderThickness="0,0,0,1">
<Grid RowDefinitions="32,48">
<Grid RowDefinitions="48">
<!-- Row 0: title / caption strip. The transparent background spans the
whole strip so PointerPressed -> BeginMoveDrag drags the window from
anywhere on it (the log button and the OS caption buttons handle their
own clicks). The OS min/max/close buttons render at its top-right, so
~140px is reserved on the right for them. -->
<Grid Grid.Row="0" Background="Transparent"
PointerPressed="TitleBar_OnPointerPressed"
ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="Starter MaSzyna" VerticalAlignment="Center"
Margin="14,0,0,0" FontSize="11"
Foreground="{StaticResource CarbonFgDim}" />
<Button Grid.Column="1" Classes="Basic" Click="openLastLogClick"
VerticalAlignment="Stretch" Margin="0,0,140,0" Padding="10,0" Cursor="Hand"
ToolTip.Tip="{Binding [ShowLatestLogTooltip], Source={x:Static starterNg:App.Loc}}">
<materialIcons:MaterialIcon Kind="FileDocument" Height="16" Width="16" />
</Button>
</Grid>
<!-- Row 1: logo + navigation (left) and the right cluster (right) -->
<Grid Grid.Row="1" ColumnDefinitions="Auto,Auto,*,Auto">
<!-- Logo + navigation (left) and the right cluster (right) -->
<!-- Drag-to-move handled by PointerPressed on parent grid -->
<Grid ColumnDefinitions="Auto,Auto,*,Auto"
Background="Transparent"
PointerPressed="TitleBar_OnPointerPressed">
<!-- Logo -->
<Image Grid.Column="0" Source="/Assets/Logo/maszyna_white.png"
@@ -70,6 +54,10 @@
Tag="settings"
IsCheckedChanged="Nav_OnCheckedChanged"
Content="{Binding [NavSettings], Source={x:Static starterNg:App.Loc}}" />
<Button Classes="Basic" Click="openLastLogClick" VerticalAlignment="Center" Padding="10,0" Cursor="Hand"
ToolTip.Tip="{Binding [ShowLatestLogTooltip], Source={x:Static starterNg:App.Loc}}">
<materialIcons:MaterialIcon Kind="FileDocument" Height="16" Width="16" />
</Button>
<Button x:Name="HelpButton" Classes="Basic" Width="48" Cursor="Hand"
ToolTip.Tip="{Binding [Help], Source={x:Static starterNg:App.Loc}}"
Click="HelpButton_OnClick"

View File

@@ -2,7 +2,7 @@
"profiles": {
"StarterNG": {
"commandName": "Project",
"workingDirectory": "E:\\Hirek\\Games\\Steam\\steamapps\\common\\MaSzyna"
"workingDirectory": "/home/maj00r/.local/share/Steam/steamapps/common/MaSzyna/"
}
}
}

View File

@@ -3,6 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:starterNg="clr-namespace:StarterNG"
xmlns:infra="clr-namespace:StarterNG.Infrastructure"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="StarterNG.Views.Scenarios"
x:CompileBindings="True">
@@ -28,7 +29,7 @@
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"
<Button x:Name="reloadButton" 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"
@@ -158,15 +159,16 @@
</TabItem>
<TabItem Header="{Binding [Timetable], Source={x:Static starterNg:App.Loc}}">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto">
<TextBlock Name="timetableContent" Margin="4" FontFamily="Consolas, monospace"
FontSize="12" />
<TextBlock Name="timetableContent" Margin="4"
FontFamily="{x:Static infra:MonoFont.Family}"
FontSize="12" TextWrapping="NoWrap" />
</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" />
FontFamily="{x:Static infra:MonoFont.Family}" FontSize="12" />
</ScrollViewer>
</TabItem>
</TabControl>

View File

@@ -6,6 +6,7 @@ using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
@@ -488,18 +489,26 @@ public partial class Scenarios : UserControl
ShowSceneryInfo(AppState.Instance.CurrentScenery);
}
private void ReloadSceneries_OnClick(object? sender, RoutedEventArgs e)
private async 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))
reloadButton.IsEnabled = false;
try
{
StarterNG.Classes.Settings.Instance.LastScenery =
Path.GetFileNameWithoutExtension(selected);
string? selected = AppState.Instance.CurrentScenery?.Path;
await Task.Run(() => GameData.Instance.ReloadSceneries());
Sceneries = GameData.Instance.Sceneries;
BuildSceneryTree();
if (!string.IsNullOrEmpty(selected))
{
StarterNG.Classes.Settings.Instance.LastScenery =
Path.GetFileNameWithoutExtension(selected);
}
RestoreLastScenery();
}
finally
{
reloadButton.IsEnabled = true;
}
RestoreLastScenery();
}
private void OpenSceneryDir_OnClick(object? sender, RoutedEventArgs e)