mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-10-30 03:30:29 +01:00
Gate Start on crew, pick starting vehicle, loading screen and help menu
#16 and #12 Start is enabled only with crew/passenger (hold Ctrl to force); the chosen starting vehicle is carried through launch; loading-screen logo; help menu; runtime language picker.
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
namespace StarterNG.Classes;
|
||||
using System;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
|
||||
/// <summary>
|
||||
/// Shared selection state across views: the scenery and trainset currently in
|
||||
@@ -10,6 +12,50 @@ public sealed class AppState
|
||||
{
|
||||
public static AppState Instance { get; } = new();
|
||||
|
||||
public Scenery? CurrentScenery { get; set; }
|
||||
public Trainset? CurrentTrainset { get; set; }
|
||||
public event Action? Changed;
|
||||
|
||||
private Scenery? _currentScenery;
|
||||
private Trainset? _currentTrainset;
|
||||
private string? _startingVehicleName;
|
||||
|
||||
public Scenery? CurrentScenery
|
||||
{
|
||||
get => _currentScenery;
|
||||
set
|
||||
{
|
||||
if (ReferenceEquals(_currentScenery, value)) return;
|
||||
_currentScenery = value;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public Trainset? CurrentTrainset
|
||||
{
|
||||
get => _currentTrainset;
|
||||
set
|
||||
{
|
||||
if (ReferenceEquals(_currentTrainset, value)) return;
|
||||
_currentTrainset = value;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Node name of the vehicle the player starts in (<c>-v</c>), like Pascal
|
||||
/// <c>SelVehicle</c>. Updated when a consist car is clicked in the depot or a
|
||||
/// trainset is picked in Scenarios.
|
||||
/// </summary>
|
||||
public string? StartingVehicleName
|
||||
{
|
||||
get => _startingVehicleName;
|
||||
set
|
||||
{
|
||||
if (_startingVehicleName == value) return;
|
||||
_startingVehicleName = value;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Notify listeners after in-place edits that affect Start enablement.</summary>
|
||||
public void NotifyChanged() => Changed?.Invoke();
|
||||
}
|
||||
|
||||
@@ -70,9 +70,9 @@
|
||||
Tag="settings"
|
||||
IsCheckedChanged="Nav_OnCheckedChanged"
|
||||
Content="{Binding [NavSettings], Source={x:Static starterNg:App.Loc}}" />
|
||||
<Button Classes="Basic" Width="48" Cursor="Hand"
|
||||
<Button x:Name="HelpButton" Classes="Basic" Width="48" Cursor="Hand"
|
||||
ToolTip.Tip="{Binding [Help], Source={x:Static starterNg:App.Loc}}"
|
||||
Tag="https://wiki.eu07.pl/" Click="linkClick"
|
||||
Click="HelpButton_OnClick"
|
||||
Content="?" FontWeight="Bold" FontSize="16"
|
||||
HorizontalContentAlignment="Center" />
|
||||
</StackPanel>
|
||||
@@ -88,11 +88,8 @@
|
||||
FontSize="10" Foreground="{StaticResource CarbonFg}" Padding="0" />
|
||||
</StackPanel>
|
||||
|
||||
<ComboBox x:Name="LangCombo" Width="100" VerticalAlignment="Center"
|
||||
SelectionChanged="LangCombo_OnSelectionChanged">
|
||||
<ComboBoxItem>Polski</ComboBoxItem>
|
||||
<ComboBoxItem>English</ComboBoxItem>
|
||||
</ComboBox>
|
||||
<ComboBox x:Name="LangCombo" Width="110" VerticalAlignment="Center"
|
||||
SelectionChanged="LangCombo_OnSelectionChanged" />
|
||||
|
||||
<Button Classes="Basic" Cursor="Hand" Tag="http://eu07.pl/" Click="linkClick">
|
||||
<materialIcons:MaterialIcon Kind="Web" />
|
||||
@@ -115,7 +112,7 @@
|
||||
</Border>
|
||||
|
||||
<!-- ── Full-width START button (bottom) ─────────────────────────────── -->
|
||||
<Button DockPanel.Dock="Bottom" Classes="Start" Height="44" Margin="6"
|
||||
<Button Name="startButton" DockPanel.Dock="Bottom" Classes="Start" Height="44" Margin="6"
|
||||
Click="StartButton_OnClick"
|
||||
Content="{Binding [Start], Source={x:Static starterNg:App.Loc}}" />
|
||||
|
||||
|
||||
@@ -9,11 +9,20 @@ using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Infrastructure;
|
||||
using StarterNG.Services;
|
||||
using StarterNG.Views;
|
||||
using SettingsModel = StarterNG.Classes.Settings;
|
||||
|
||||
namespace StarterNG;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
// Tracks Ctrl for Start bypass (Pascal btnStart.Tag while Ctrl held).
|
||||
private KeyModifiers _mods;
|
||||
private DateTime _openedUtc = DateTime.UtcNow;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
@@ -29,8 +38,23 @@ public partial class MainWindow : Window
|
||||
// buttons fill this height at the top-right, sitting above the social row.
|
||||
ExtendClientAreaTitleBarHeightHint = 32;
|
||||
|
||||
// Mirror the active language into the top-bar selector.
|
||||
LangCombo.SelectedIndex = App.Loc.CurrentLanguage == "Polski" ? 0 : 1;
|
||||
PopulateLangCombo();
|
||||
|
||||
// Keep Start enabled/caption in sync with selection (Pascal actStartUpdate).
|
||||
AppState.Instance.Changed += UpdateStartButton;
|
||||
Opened += (_, _) =>
|
||||
{
|
||||
_openedUtc = DateTime.UtcNow;
|
||||
UpdateStartButton();
|
||||
};
|
||||
Activated += (_, _) => CheckExternalSettings();
|
||||
AddHandler(InputElement.KeyDownEvent, (_, e) => { _mods = e.KeyModifiers; UpdateStartButton(); }, handledEventsToo: true);
|
||||
AddHandler(InputElement.KeyUpEvent, (_, e) => { _mods = e.KeyModifiers; UpdateStartButton(); }, handledEventsToo: true);
|
||||
AddHandler(InputElement.PointerMovedEvent, (_, e) =>
|
||||
{
|
||||
var m = e.KeyModifiers;
|
||||
if (m != _mods) { _mods = m; UpdateStartButton(); }
|
||||
}, handledEventsToo: true);
|
||||
}
|
||||
|
||||
// Lets the user drag the window by the top caption strip.
|
||||
@@ -56,13 +80,70 @@ public partial class MainWindow : Window
|
||||
SettingsView.IsVisible = page == "settings";
|
||||
}
|
||||
|
||||
private void PopulateLangCombo()
|
||||
{
|
||||
LangCombo.Items.Clear();
|
||||
int selected = 0;
|
||||
var langs = LocalizationService.AvailableLanguages();
|
||||
if (langs.Count == 0)
|
||||
{
|
||||
LangCombo.Items.Add(new ComboBoxItem { Content = "English" });
|
||||
LangCombo.SelectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < langs.Count; i++)
|
||||
{
|
||||
LangCombo.Items.Add(new ComboBoxItem { Content = langs[i].Name, Tag = langs[i].Code });
|
||||
if (string.Equals(langs[i].Name, App.Loc.CurrentLanguage, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(langs[i].Code, App.Loc.CurrentLangCode, StringComparison.OrdinalIgnoreCase))
|
||||
selected = i;
|
||||
}
|
||||
LangCombo.SelectedIndex = selected;
|
||||
}
|
||||
|
||||
private void LangCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (sender is not ComboBox { SelectedItem: ComboBoxItem item }) return;
|
||||
if (!item.IsKeyboardFocusWithin) return; // ignore the programmatic initial set
|
||||
var lang = item.Content?.ToString();
|
||||
var lang = item.Tag as string ?? item.Content?.ToString();
|
||||
if (!string.IsNullOrEmpty(lang))
|
||||
{
|
||||
App.ApplyLanguage(lang);
|
||||
ScenariosView?.RebuildAfterLanguageChange();
|
||||
}
|
||||
}
|
||||
|
||||
private void HelpButton_OnClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var menu = new ContextMenu();
|
||||
|
||||
var wiki = new MenuItem { Header = App.Loc["HelpWiki"] };
|
||||
wiki.Click += (_, _) => OpenPathOrUrl("https://wiki.eu07.pl/");
|
||||
menu.Items.Add(wiki);
|
||||
|
||||
string readme = string.Equals(App.Loc.CurrentLangCode, "pl", StringComparison.OrdinalIgnoreCase)
|
||||
? "readme.html" : "en-readme.html";
|
||||
if (File.Exists(readme) || File.Exists(Path.Combine(Directory.GetCurrentDirectory(), readme)))
|
||||
{
|
||||
var mi = new MenuItem { Header = App.Loc["HelpReadme"] };
|
||||
mi.Click += (_, _) => OpenPathOrUrl(readme);
|
||||
menu.Items.Add(mi);
|
||||
}
|
||||
|
||||
string regs = Path.Combine(Directory.GetCurrentDirectory(), "przepisy_kolejowe");
|
||||
if (Directory.Exists(regs))
|
||||
{
|
||||
menu.Items.Add(new Separator());
|
||||
foreach (string pdf in Directory.GetFiles(regs, "*.pdf").OrderBy(Path.GetFileName))
|
||||
{
|
||||
string file = pdf;
|
||||
var mi = new MenuItem { Header = Path.GetFileName(file) };
|
||||
mi.Click += (_, _) => OpenPathOrUrl(file);
|
||||
menu.Items.Add(mi);
|
||||
}
|
||||
}
|
||||
|
||||
menu.Open(HelpButton);
|
||||
}
|
||||
|
||||
private void linkClick(object? sender, RoutedEventArgs e)
|
||||
@@ -73,10 +154,23 @@ public partial class MainWindow : Window
|
||||
if (btn.Tag is not string url || string.IsNullOrWhiteSpace(url))
|
||||
return;
|
||||
|
||||
Process.Start(new ProcessStartInfo(url)
|
||||
OpenPathOrUrl(url);
|
||||
}
|
||||
|
||||
private static void OpenPathOrUrl(string pathOrUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
UseShellExecute = true
|
||||
});
|
||||
string target = pathOrUrl;
|
||||
if (!pathOrUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
target = Path.GetFullPath(pathOrUrl);
|
||||
if (!File.Exists(target) && !Directory.Exists(target))
|
||||
return;
|
||||
}
|
||||
Process.Start(new ProcessStartInfo(target) { UseShellExecute = true });
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private void openLastLogClick(object? sender, RoutedEventArgs e)
|
||||
@@ -91,6 +185,55 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
// Pascal actStartUpdate: Start needs scenery + trainset + staffed/passenger
|
||||
// vehicle. Hold Ctrl to force START (bypass crew check).
|
||||
private void UpdateStartButton()
|
||||
{
|
||||
if (startButton is null) return;
|
||||
|
||||
var scenery = AppState.Instance.CurrentScenery;
|
||||
var trainset = AppState.Instance.CurrentTrainset;
|
||||
bool ctrl = _mods.HasFlag(KeyModifiers.Control);
|
||||
|
||||
if (scenery is null)
|
||||
{
|
||||
startButton.Content = App.Loc["StartNoScenery"];
|
||||
startButton.IsEnabled = false;
|
||||
return;
|
||||
}
|
||||
if (trainset is null)
|
||||
{
|
||||
startButton.Content = App.Loc["StartNoTrain"];
|
||||
startButton.IsEnabled = false;
|
||||
return;
|
||||
}
|
||||
if (!HasStartableVehicle(trainset) && !ctrl)
|
||||
{
|
||||
startButton.Content = App.Loc["StartNoStaff"];
|
||||
startButton.IsEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
startButton.Content = App.Loc["Start"];
|
||||
startButton.IsEnabled = true;
|
||||
}
|
||||
|
||||
private static bool HasStartableVehicle(Trainset trainset)
|
||||
{
|
||||
static bool CanStart(Dynamic v) =>
|
||||
v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
|
||||
|
||||
string? selected = AppState.Instance.StartingVehicleName;
|
||||
if (!string.IsNullOrEmpty(selected))
|
||||
{
|
||||
var pick = trainset.Vehicles.FirstOrDefault(v =>
|
||||
string.Equals(v.Name, selected, StringComparison.OrdinalIgnoreCase));
|
||||
if (pick != null)
|
||||
return CanStart(pick);
|
||||
}
|
||||
return trainset.Vehicles.Any(CanStart);
|
||||
}
|
||||
|
||||
// ── START: export the (possibly depot-modified) scenery and launch the sim.
|
||||
// Global on the main window, like the original Starter's bottom START button.
|
||||
private void StartButton_OnClick(object? sender, RoutedEventArgs e)
|
||||
@@ -99,10 +242,15 @@ public partial class MainWindow : Window
|
||||
if (scenery is null)
|
||||
return;
|
||||
var trainset = AppState.Instance.CurrentTrainset;
|
||||
if (trainset is null)
|
||||
return;
|
||||
|
||||
if (trainset is { Vehicles.Count: > 0 })
|
||||
if (!HasStartableVehicle(trainset) && !_mods.HasFlag(KeyModifiers.Control))
|
||||
return;
|
||||
|
||||
if (trainset.Vehicles.Count > 0)
|
||||
{
|
||||
trainset.Velocity = Settings.Instance.BatteryDefault switch
|
||||
trainset.Velocity = SettingsModel.Instance.BatteryDefault switch
|
||||
{
|
||||
BatteryDefault.AlwaysOff => 0f,
|
||||
BatteryDefault.AlwaysOn => MathF.Abs(trainset.OriginalVelocity) < 0.01f ? 0.1f : trainset.OriginalVelocity,
|
||||
@@ -110,26 +258,34 @@ public partial class MainWindow : Window
|
||||
};
|
||||
}
|
||||
|
||||
// Pascal UniqueVehicleName / UniqueVehiclesName before writing $scn.
|
||||
string? vehicle = TrainsetDisplay.UniquifyForLaunch(
|
||||
trainset, scenery, AppState.Instance.StartingVehicleName);
|
||||
if (string.IsNullOrEmpty(vehicle))
|
||||
vehicle = ResolveStartVehicle(trainset);
|
||||
AppState.Instance.StartingVehicleName = vehicle;
|
||||
|
||||
LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path));
|
||||
|
||||
// Always inject the Weather-tab values (incl. start-time override), like the
|
||||
// Pascal starter's ChangeConfig on every launch — not only after an edit.
|
||||
scenery.WeatherDirty = true;
|
||||
|
||||
// write scenery/$<name>.scn with the replaced trainsets
|
||||
string dir = Path.GetDirectoryName(scenery.Path) ?? "scenery";
|
||||
string exportName = "$" + Path.GetFileName(scenery.Path);
|
||||
string exportPath = Path.Combine(dir, exportName);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(exportPath, scenery.BuildExportContent(Settings.Instance.IgnoreIrrelevantTrains), Encoding.GetEncoding(1250));
|
||||
File.WriteAllText(exportPath, scenery.BuildExportContent(SettingsModel.Instance.IgnoreIrrelevantTrains), 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;
|
||||
|
||||
// make sure the latest settings are on disk before the sim reads them
|
||||
Settings.Instance.CaptureAndSave();
|
||||
SettingsModel.Instance.CaptureAndSave();
|
||||
|
||||
// launch the game: -s $<name>.scn -v <vehicle>. Keep the handle so we can
|
||||
// watch for the simulator exiting. UseShellExecute = false runs the binary
|
||||
@@ -138,7 +294,7 @@ public partial class MainWindow : Window
|
||||
Process? sim;
|
||||
try
|
||||
{
|
||||
string exe = Path.GetFullPath(Settings.Instance.ResolveExecutable());
|
||||
string exe = Path.GetFullPath(SettingsModel.Instance.ResolveExecutable());
|
||||
sim = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = exe,
|
||||
@@ -153,7 +309,7 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
// honour the "close starter automatically" preference
|
||||
if (Settings.Instance.AutoCloseStarter)
|
||||
if (SettingsModel.Instance.AutoCloseStarter)
|
||||
{
|
||||
Close();
|
||||
return;
|
||||
@@ -170,6 +326,29 @@ public partial class MainWindow : Window
|
||||
WatchSimulator(sim);
|
||||
}
|
||||
|
||||
// Pascal: RunInfo.Vehicle := Train.Vehicles[SelVehicle].Name after actStartUpdate
|
||||
// requires headdriver/reardriver/passenger on that car.
|
||||
private static string? ResolveStartVehicle(Trainset? trainset)
|
||||
{
|
||||
if (trainset is null || trainset.Vehicles.Count == 0)
|
||||
return null;
|
||||
|
||||
static bool CanStart(Dynamic v) =>
|
||||
v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
|
||||
|
||||
string? selected = AppState.Instance.StartingVehicleName;
|
||||
if (!string.IsNullOrEmpty(selected))
|
||||
{
|
||||
var pick = trainset.Vehicles.FirstOrDefault(v =>
|
||||
string.Equals(v.Name, selected, StringComparison.OrdinalIgnoreCase));
|
||||
if (pick != null && CanStart(pick))
|
||||
return pick.Name;
|
||||
}
|
||||
|
||||
return trainset.Vehicles.FirstOrDefault(CanStart)?.Name
|
||||
?? trainset.Vehicles.FirstOrDefault()?.Name;
|
||||
}
|
||||
|
||||
// Waits (off the UI thread) for the simulator to exit, then restores the
|
||||
// starter window. Uses the launch handle when available, otherwise locates the
|
||||
// process by name, and never leaves the UI hidden if it cannot be tracked.
|
||||
@@ -177,7 +356,7 @@ public partial class MainWindow : Window
|
||||
{
|
||||
if (sim is null)
|
||||
{
|
||||
string name = Path.GetFileNameWithoutExtension(Settings.Instance.ResolveExecutable());
|
||||
string name = Path.GetFileNameWithoutExtension(SettingsModel.Instance.ResolveExecutable());
|
||||
sim = Process.GetProcessesByName(name).FirstOrDefault();
|
||||
}
|
||||
|
||||
@@ -201,6 +380,28 @@ public partial class MainWindow : Window
|
||||
watcher.Start();
|
||||
}
|
||||
|
||||
// Pascal CheckSettingsFile on AppActivate (skip the first few seconds).
|
||||
private async void CheckExternalSettings()
|
||||
{
|
||||
if ((DateTime.UtcNow - _openedUtc).TotalSeconds < 3)
|
||||
return;
|
||||
if (!SettingsModel.Instance.IsConfigNewerOnDisk())
|
||||
return;
|
||||
|
||||
var result = await MessageBox.Show(
|
||||
this,
|
||||
App.Loc["SettingsFileChanged"],
|
||||
App.Loc["SettingsFileChangedTitle"],
|
||||
MessageBoxButtons.YesNo);
|
||||
if (result)
|
||||
{
|
||||
SettingsModel.Instance.Load();
|
||||
SettingsView?.ReloadFromSettings();
|
||||
}
|
||||
else
|
||||
SettingsModel.Instance.TouchConfigAge();
|
||||
}
|
||||
|
||||
// Brings the starter back to the foreground after the simulator closes.
|
||||
private void RestoreFromSimulator()
|
||||
{
|
||||
|
||||
56
StarterNG/Views/MessageBox.cs
Normal file
56
StarterNG/Views/MessageBox.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
public enum MessageBoxButtons
|
||||
{
|
||||
YesNo
|
||||
}
|
||||
|
||||
public static class MessageBox
|
||||
{
|
||||
/// <summary>Simple Yes/No dialog. Returns true for Yes.</summary>
|
||||
public static async Task<bool> Show(Window owner, string message, string title, MessageBoxButtons _)
|
||||
{
|
||||
var win = new Window
|
||||
{
|
||||
Title = title,
|
||||
Width = 420,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
CanResize = false
|
||||
};
|
||||
|
||||
bool yes = false;
|
||||
var yesBtn = new Button { Content = App.Loc["Yes"], MinWidth = 80, Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand) };
|
||||
yesBtn.Classes.Add("Accent");
|
||||
yesBtn.Click += (_, _) => { yes = true; win.Close(); };
|
||||
|
||||
var noBtn = new Button { Content = App.Loc["No"], MinWidth = 80, Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand) };
|
||||
noBtn.Classes.Add("Flat");
|
||||
noBtn.Click += (_, _) => win.Close();
|
||||
|
||||
win.Content = new StackPanel
|
||||
{
|
||||
Margin = new Avalonia.Thickness(16),
|
||||
Spacing = 16,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Spacing = 8,
|
||||
Children = { yesBtn, noBtn }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await win.ShowDialog(owner);
|
||||
return yes;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user