mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-10-17 07:59:19 +02:00
Take the scenario presentation logic out of the code-behind
Scenery tree, fog scale, season dates and timetable lookup become their own classes.
This commit is contained in:
@@ -19,15 +19,30 @@ public sealed class InMemoryFileSystem : IFileSystem
|
||||
|
||||
public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>Adds a text file, creating its parent directories.</summary>
|
||||
public InMemoryFileSystem WithFile(string path, string contents)
|
||||
static InMemoryFileSystem()
|
||||
{
|
||||
// The game's own files are code page 1250; a test that writes Polish text
|
||||
// into one needs the provider registered just as the application does.
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
}
|
||||
|
||||
/// <summary>Adds a UTF-8 text file, creating its parent directories.</summary>
|
||||
public InMemoryFileSystem WithFile(string path, string contents) =>
|
||||
WithFile(path, contents, Encoding.UTF8);
|
||||
|
||||
/// <summary>Adds a text file in a given encoding, as the game's files are stored.</summary>
|
||||
public InMemoryFileSystem WithFile(string path, string contents, Encoding encoding)
|
||||
{
|
||||
string key = Normalize(path);
|
||||
_files[key] = Encoding.UTF8.GetBytes(contents);
|
||||
_files[key] = encoding.GetBytes(contents);
|
||||
AddParents(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Adds a file in the code page the .scn and .fiz formats use.</summary>
|
||||
public InMemoryFileSystem WithLegacyFile(string path, string contents) =>
|
||||
WithFile(path, contents, Encoding.GetEncoding(1250));
|
||||
|
||||
/// <summary>Adds an empty directory.</summary>
|
||||
public InMemoryFileSystem WithDirectory(string path)
|
||||
{
|
||||
|
||||
167
StarterNG.Tests/ScenarioPresentationTests.cs
Normal file
167
StarterNG.Tests/ScenarioPresentationTests.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
using StarterNG.Presentation.Scenarios;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class FogScaleTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_slider_ends_land_on_the_supported_visibility_range()
|
||||
{
|
||||
Assert.Equal(SceneryWeather.FogMin, FogScale.ToMetres(0));
|
||||
Assert.Equal(SceneryWeather.FogMax, FogScale.ToMetres(FogScale.SliderRange));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_distance_survives_a_round_trip_through_the_slider()
|
||||
{
|
||||
foreach (int metres in new[] { 50, 200, 1500, 8000 })
|
||||
Assert.Equal(metres, FogScale.ToMetres(FogScale.ToSlider(metres)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Distances_snap_to_a_step_that_grows_with_the_range()
|
||||
{
|
||||
Assert.Equal(0, FogScale.ToMetres(200) % 5);
|
||||
Assert.Equal(0, FogScale.ToMetres(700) % 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Metres_below_a_kilometre_kilometres_above_it()
|
||||
{
|
||||
var culture = System.Globalization.CultureInfo.InvariantCulture;
|
||||
|
||||
Assert.Equal("250 m", FogScale.Format(250, culture));
|
||||
Assert.Equal("2.5 km", FogScale.Format(2500, culture));
|
||||
}
|
||||
}
|
||||
|
||||
public class SeasonDatesTests
|
||||
{
|
||||
private static SeasonDates On(DateTime now) => new(new FixedClock(now));
|
||||
|
||||
[Fact]
|
||||
public void A_stored_day_of_year_becomes_that_day_in_the_current_year()
|
||||
{
|
||||
var date = On(new DateTime(2026, 7, 1)).DateOf(100);
|
||||
|
||||
Assert.Equal(2026, date.Year);
|
||||
Assert.Equal(100, date.DayOfYear);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Day_zero_means_today()
|
||||
{
|
||||
var now = new DateTime(2026, 5, 4);
|
||||
|
||||
Assert.Equal(now.DayOfYear, On(now).DateOf(0).DayOfYear);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_last_day_of_a_leap_year_stays_selectable()
|
||||
{
|
||||
Assert.Equal(366, On(new DateTime(2028, 1, 1)).DateOf(366).DayOfYear);
|
||||
Assert.Equal(365, On(new DateTime(2026, 1, 1)).DateOf(400).DayOfYear);
|
||||
}
|
||||
}
|
||||
|
||||
public class SceneryTreeBuilderTests
|
||||
{
|
||||
private static (SceneryTreeBuilder Builder, List<Scenery> Sceneries) Build(params string[] contents)
|
||||
{
|
||||
var files = new InMemoryFileSystem();
|
||||
for (int i = 0; i < contents.Length; i++)
|
||||
files.WithLegacyFile(TestInstallation.At("scenery", $"s{i}.scn"), contents[i]);
|
||||
|
||||
var installation = new TestInstallation(files);
|
||||
var sceneries = installation.Sceneries.LoadAll().ToList();
|
||||
return (new SceneryTreeBuilder(new SceneryTranslations(files, installation.Log)), sceneries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scenarios_declaring_a_group_are_gathered_under_it()
|
||||
{
|
||||
var (builder, sceneries) = Build("//$l Śląsk", "//$l Śląsk", "//$n Bez grupy");
|
||||
|
||||
var nodes = builder.Build(sceneries, includeArchival: true, "en");
|
||||
|
||||
var group = Assert.Single(nodes, node => node.IsGroup);
|
||||
Assert.Equal("Śląsk", group.Label);
|
||||
Assert.Equal(2, group.Children.Count);
|
||||
Assert.Single(nodes, node => !node.IsGroup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Archival_scenarios_are_left_out_unless_asked_for()
|
||||
{
|
||||
var (builder, sceneries) = Build("//$a", "//$n Zwykla");
|
||||
|
||||
Assert.Single(builder.Build(sceneries, includeArchival: false, "en"));
|
||||
Assert.Equal(2, builder.Build(sceneries, includeArchival: true, "en").Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nodes_carry_the_index_of_the_scenery_they_stand_for()
|
||||
{
|
||||
var (builder, sceneries) = Build("//$n Pierwsza", "//$n Druga");
|
||||
|
||||
var nodes = builder.Build(sceneries, includeArchival: true, "en");
|
||||
|
||||
foreach (var node in nodes)
|
||||
Assert.Equal(node.Label, sceneries[node.SceneryIndex].DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Both_levels_are_sorted_by_name()
|
||||
{
|
||||
var (builder, sceneries) = Build("//$l Zet", "//$l Alfa", "//$l Alfa");
|
||||
|
||||
var nodes = builder.Build(sceneries, includeArchival: true, "en");
|
||||
|
||||
Assert.Equal(new[] { "Alfa", "Zet" }, nodes.Select(node => node.Label));
|
||||
Assert.Equal(nodes[0].Children.Select(child => child.Label).OrderBy(label => label),
|
||||
nodes[0].Children.Select(child => child.Label));
|
||||
}
|
||||
}
|
||||
|
||||
public class TimetableLocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_timetable_is_found_in_the_shared_folder_first()
|
||||
{
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice")
|
||||
.WithFile(TestInstallation.At("timetables", "r1.txt"), "rozkład")
|
||||
.WithFile(TestInstallation.At("scenery", "r1.txt"), "rozkład obok scenerii");
|
||||
var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll());
|
||||
|
||||
Assert.Equal(TestInstallation.At("timetables", "r1.txt"),
|
||||
new TimetableLocator(files).Resolve(scenery, "r1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_name_already_carrying_its_extension_is_found_too()
|
||||
{
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice")
|
||||
.WithFile(TestInstallation.At("timetables", "r1.txt"), "rozkład");
|
||||
var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll());
|
||||
|
||||
Assert.NotNull(new TimetableLocator(files).Resolve(scenery, "r1.txt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_formats_none_and_a_missing_file_both_resolve_to_nothing()
|
||||
{
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice");
|
||||
var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll());
|
||||
var locator = new TimetableLocator(files);
|
||||
|
||||
Assert.Null(locator.Resolve(scenery, "none"));
|
||||
Assert.Null(locator.Resolve(scenery, "nieistniejacy"));
|
||||
Assert.Null(locator.Resolve(scenery, null));
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ public sealed class AppServices
|
||||
Physics = new FizPhysicsRepository(files, paths, log);
|
||||
MissingAssets = new MissingAssetScanner(files, paths);
|
||||
SceneryTexts = new SceneryTranslations(files, log);
|
||||
Timetables = new TimetableLocator(files);
|
||||
LoadWeights = new LoadWeightsRepository(files, paths, log);
|
||||
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
|
||||
State = new AppState();
|
||||
@@ -109,6 +110,8 @@ public sealed class AppServices
|
||||
|
||||
public ISceneryTranslations SceneryTexts { get; }
|
||||
|
||||
public TimetableLocator Timetables { get; }
|
||||
|
||||
public LoadWeightsRepository LoadWeights { get; }
|
||||
|
||||
/// <summary>Everything read out of the installation: rolling stock and scenarios.</summary>
|
||||
|
||||
48
StarterNG/Infrastructure/Sceneries/TimetableLocator.cs
Normal file
48
StarterNG/Infrastructure/Sceneries/TimetableLocator.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Infrastructure.Sceneries;
|
||||
|
||||
/// <summary>
|
||||
/// Finds the timetable file a consist names.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Timetables live in the shared timetables/ folder, next to the scenery, or in
|
||||
/// scenario/ depending on the installation's vintage, and the name may or may
|
||||
/// not carry its .txt extension. "none" is the format's way of saying a consist
|
||||
/// runs without one.
|
||||
/// </remarks>
|
||||
public sealed class TimetableLocator
|
||||
{
|
||||
private const string NoTimetable = "none";
|
||||
|
||||
private readonly IFileSystem _files;
|
||||
|
||||
public TimetableLocator(IFileSystem files)
|
||||
{
|
||||
_files = files;
|
||||
}
|
||||
|
||||
public string? Resolve(Scenery scenery, string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name) || name.Equals(NoTimetable, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
string sceneryDir = Path.GetDirectoryName(scenery.Path) ?? ".";
|
||||
string root = Path.GetDirectoryName(sceneryDir) ?? ".";
|
||||
|
||||
string[] candidates =
|
||||
{
|
||||
Path.Combine(root, "timetables", name + ".txt"),
|
||||
Path.Combine(sceneryDir, name + ".txt"),
|
||||
Path.Combine(root, "scenario", name + ".txt"),
|
||||
Path.Combine(root, "timetables", name),
|
||||
Path.Combine(sceneryDir, name)
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(_files.FileExists);
|
||||
}
|
||||
}
|
||||
45
StarterNG/Presentation/Scenarios/FogScale.cs
Normal file
45
StarterNG/Presentation/Scenarios/FogScale.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Presentation.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Converts between the weather tab's fog slider and a distance in metres.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Visibility runs from 10 m to 10 km, a range no linear slider handles well:
|
||||
/// the interesting part - thick fog, where a few metres matter - would be a
|
||||
/// couple of pixels wide. The slider is therefore logarithmic over its 0-1000
|
||||
/// travel, and the result snaps to a step that grows with the distance, so the
|
||||
/// read-out lands on round numbers a driver would recognise.
|
||||
/// </remarks>
|
||||
public static class FogScale
|
||||
{
|
||||
/// <summary>Travel of the slider the scale is spread over.</summary>
|
||||
public const double SliderRange = 1000.0;
|
||||
|
||||
public static int ToMetres(double sliderPosition)
|
||||
{
|
||||
double metres = SceneryWeather.FogMin *
|
||||
Math.Pow((double)SceneryWeather.FogMax / SceneryWeather.FogMin,
|
||||
sliderPosition / SliderRange);
|
||||
|
||||
int step = metres < 100 ? 5 : metres < 1000 ? 10 : 100;
|
||||
return Math.Clamp((int)(Math.Round(metres / step) * step),
|
||||
SceneryWeather.FogMin, SceneryWeather.FogMax);
|
||||
}
|
||||
|
||||
public static double ToSlider(int metres)
|
||||
{
|
||||
double clamped = Math.Clamp(metres, SceneryWeather.FogMin, SceneryWeather.FogMax);
|
||||
return SliderRange * Math.Log(clamped / SceneryWeather.FogMin) /
|
||||
Math.Log((double)SceneryWeather.FogMax / SceneryWeather.FogMin);
|
||||
}
|
||||
|
||||
/// <summary>Metres below a kilometre, kilometres above it.</summary>
|
||||
public static string Format(int metres, CultureInfo culture) =>
|
||||
metres < 1000
|
||||
? $"{metres} m"
|
||||
: string.Format(culture, "{0:0.#} km", metres / 1000.0);
|
||||
}
|
||||
84
StarterNG/Presentation/Scenarios/SceneryTree.cs
Normal file
84
StarterNG/Presentation/Scenarios/SceneryTree.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Presentation.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// A node of the scenery list: either a scenario, or a group holding scenarios.
|
||||
/// </summary>
|
||||
/// <param name="Label">Text to show.</param>
|
||||
/// <param name="SceneryIndex">Index into the scenery list, or -1 for a group.</param>
|
||||
/// <param name="Children">Scenarios under a group; empty for a scenario.</param>
|
||||
public sealed record SceneryTreeNode(string Label, int SceneryIndex, IReadOnlyList<SceneryTreeNode> Children)
|
||||
{
|
||||
public bool IsGroup => SceneryIndex < 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns the flat scenery list into the tree the scenario picker shows:
|
||||
/// scenarios that declare a group sit under it, the rest stay at the top, and
|
||||
/// everything is sorted by name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pulled out of the view so the grouping and the archival filter can be tested
|
||||
/// without a TreeView. The view's job is reduced to turning these nodes into
|
||||
/// controls.
|
||||
/// </remarks>
|
||||
public sealed class SceneryTreeBuilder
|
||||
{
|
||||
private readonly ISceneryTranslations _translations;
|
||||
|
||||
public SceneryTreeBuilder(ISceneryTranslations translations)
|
||||
{
|
||||
_translations = translations;
|
||||
}
|
||||
|
||||
/// <param name="sceneries">The list the node indices refer to.</param>
|
||||
/// <param name="includeArchival">Whether scenarios marked archival are listed.</param>
|
||||
/// <param name="langCode">Language for resolving each scenery's own translations.</param>
|
||||
public IReadOnlyList<SceneryTreeNode> Build(IReadOnlyList<Scenery> sceneries, bool includeArchival,
|
||||
string langCode)
|
||||
{
|
||||
var groups = new Dictionary<string, List<SceneryTreeNode>>(StringComparer.Ordinal);
|
||||
var groupLabels = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var topLevel = new List<SceneryTreeNode>();
|
||||
|
||||
for (int i = 0; i < sceneries.Count; i++)
|
||||
{
|
||||
var scenery = sceneries[i];
|
||||
if (scenery.Archival && !includeArchival)
|
||||
continue;
|
||||
|
||||
// The group name may itself be an "@key" reference into the scenery's
|
||||
// own translation files, so the table has to be loaded per scenery.
|
||||
_translations.LoadFor(scenery, langCode);
|
||||
|
||||
var node = new SceneryTreeNode(scenery.DisplayName, i, Array.Empty<SceneryTreeNode>());
|
||||
|
||||
if (string.IsNullOrEmpty(scenery.Group))
|
||||
{
|
||||
topLevel.Add(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!groups.TryGetValue(scenery.Group, out var members))
|
||||
{
|
||||
members = new List<SceneryTreeNode>();
|
||||
groups[scenery.Group] = members;
|
||||
groupLabels[scenery.Group] = _translations.Translate(scenery.Group);
|
||||
}
|
||||
members.Add(node);
|
||||
}
|
||||
|
||||
foreach (var (group, members) in groups)
|
||||
topLevel.Add(new SceneryTreeNode(groupLabels[group], -1, Sorted(members)));
|
||||
|
||||
return Sorted(topLevel);
|
||||
}
|
||||
|
||||
private static List<SceneryTreeNode> Sorted(IEnumerable<SceneryTreeNode> nodes) =>
|
||||
nodes.OrderBy(node => node.Label, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
39
StarterNG/Presentation/Scenarios/SeasonDates.cs
Normal file
39
StarterNG/Presentation/Scenarios/SeasonDates.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using StarterNG.Application.Abstractions;
|
||||
|
||||
namespace StarterNG.Presentation.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Translates between the scenery's day-of-year (movelight) and the date the
|
||||
/// weather tab shows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Day 0 is the scenery saying "whatever today is", which the tab presents as
|
||||
/// the current date; the season presets are ordinary days of the year. Leap
|
||||
/// years are honoured so 366 stays selectable in one.
|
||||
/// </remarks>
|
||||
public sealed class SeasonDates
|
||||
{
|
||||
private readonly IClock _clock;
|
||||
|
||||
public SeasonDates(IClock clock)
|
||||
{
|
||||
_clock = clock;
|
||||
}
|
||||
|
||||
/// <summary>The day-of-year a scenery means by 0: today.</summary>
|
||||
public int Today => _clock.Now.DayOfYear;
|
||||
|
||||
/// <summary>
|
||||
/// The date to show for a stored day-of-year, in the current year. Day 0 and
|
||||
/// out-of-range values fall back to today, clamped to the year's length.
|
||||
/// </summary>
|
||||
public DateTimeOffset DateOf(int dayOfYear)
|
||||
{
|
||||
int year = _clock.Now.Year;
|
||||
int lastDay = DateTime.IsLeapYear(year) ? 366 : 365;
|
||||
int day = Math.Clamp(dayOfYear <= 0 ? Today : dayOfYear, 1, lastDay);
|
||||
|
||||
return new DateTimeOffset(new DateTime(year, 1, 1).AddDays(day - 1));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Presentation.Scenarios;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
@@ -65,60 +66,29 @@ public partial class Scenarios : UserControl
|
||||
{
|
||||
sceneryList.Items.Clear();
|
||||
|
||||
bool showArchival = archivalSwitch.IsChecked != true;
|
||||
var groupNodes = new Dictionary<string, TreeViewItem>();
|
||||
var topLevel = new List<TreeViewItem>();
|
||||
bool includeArchival = archivalSwitch.IsChecked != true;
|
||||
bool expandGroups = AppServices.Current.Settings.AutoExpandSceneryTree;
|
||||
|
||||
for (int i = 0; i < Sceneries.Count; i++)
|
||||
var nodes = new SceneryTreeBuilder(AppServices.Current.SceneryTexts)
|
||||
.Build(Sceneries, includeArchival, App.Loc.CurrentLangCode);
|
||||
|
||||
foreach (var node in nodes)
|
||||
sceneryList.Items.Add(ToTreeItem(node, expandGroups));
|
||||
}
|
||||
|
||||
private static TreeViewItem ToTreeItem(SceneryTreeNode node, bool expandGroups)
|
||||
{
|
||||
var item = new TreeViewItem
|
||||
{
|
||||
var scenery = Sceneries[i];
|
||||
if (scenery.Archival && !showArchival)
|
||||
continue;
|
||||
Header = node.Label,
|
||||
Tag = node.IsGroup ? null : node.SceneryIndex,
|
||||
IsExpanded = node.IsGroup && expandGroups
|
||||
};
|
||||
|
||||
bool expand = AppServices.Current.Settings.AutoExpandSceneryTree;
|
||||
AppServices.Current.SceneryTexts.LoadFor(scenery, App.Loc.CurrentLangCode);
|
||||
string label = scenery.DisplayName;
|
||||
foreach (var child in node.Children)
|
||||
item.Items.Add(ToTreeItem(child, expandGroups));
|
||||
|
||||
if (string.IsNullOrEmpty(scenery.Group))
|
||||
{
|
||||
topLevel.Add(new TreeViewItem
|
||||
{
|
||||
Header = label,
|
||||
Tag = i
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!groupNodes.TryGetValue(scenery.Group, out var groupNode))
|
||||
{
|
||||
groupNode = new TreeViewItem
|
||||
{
|
||||
Header = AppServices.Current.SceneryTexts.Translate(scenery.Group),
|
||||
IsExpanded = expand
|
||||
};
|
||||
groupNodes[scenery.Group] = groupNode;
|
||||
topLevel.Add(groupNode);
|
||||
}
|
||||
|
||||
groupNode.Items.Add(new TreeViewItem
|
||||
{
|
||||
Header = label,
|
||||
Tag = i
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var node in groupNodes.Values)
|
||||
{
|
||||
var children = node.Items.Cast<TreeViewItem>()
|
||||
.OrderBy(c => c.Header as string, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
node.Items.Clear();
|
||||
foreach (var child in children)
|
||||
node.Items.Add(child);
|
||||
}
|
||||
|
||||
foreach (var item in topLevel.OrderBy(t => t.Header as string, StringComparer.OrdinalIgnoreCase))
|
||||
sceneryList.Items.Add(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
private void RestoreLastScenery()
|
||||
@@ -555,9 +525,9 @@ public partial class Scenarios : UserControl
|
||||
weatherForm.IsEnabled = true;
|
||||
var weather = scenery.Weather;
|
||||
weatherTime.SelectedTime = ParseTime(weather.ScenarioTimeOverride);
|
||||
weatherDate.SelectedDate = DateOfDayOfYear(weather.Day);
|
||||
weatherDate.SelectedDate = Seasons.DateOf(weather.Day);
|
||||
weatherTemp.Value = weather.Temperature;
|
||||
weatherFog.Value = SliderFromFog(weather.FogEnd);
|
||||
weatherFog.Value = FogScale.ToSlider(weather.FogEnd);
|
||||
SelectByTag(weatherSeason, weather.Day.ToString(), excludeFromNearest: "0");
|
||||
SelectByTag(weatherOvercast, weather.OvercastRandom
|
||||
? RandomTag
|
||||
@@ -583,7 +553,7 @@ public partial class Scenarios : UserControl
|
||||
|
||||
weather.Day = _todaySeason ? 0 : SelectedDayOfYear();
|
||||
weather.Temperature = weatherTemp.Value;
|
||||
weather.FogEnd = FogFromSlider(weatherFog.Value);
|
||||
weather.FogEnd = FogScale.ToMetres(weatherFog.Value);
|
||||
if ((weatherOvercast.SelectedItem as ComboBoxItem)?.Tag is string ov)
|
||||
{
|
||||
weather.OvercastRandom = ov == RandomTag;
|
||||
@@ -599,32 +569,12 @@ public partial class Scenarios : UserControl
|
||||
private void UpdateWeatherLabels()
|
||||
{
|
||||
weatherTempLabel.Text = $"{(int)weatherTemp.Value} °C";
|
||||
weatherFogLabel.Text = FormatFog(FogFromSlider(weatherFog.Value));
|
||||
weatherFogLabel.Text = FogScale2(FogScale.ToMetres(weatherFog.Value));
|
||||
weatherDayDate.Text = string.Format(LanguageCulture, App.Loc["DayOfYear"], SelectedDayOfYear());
|
||||
|
||||
weatherRestore.IsEnabled = _currentScenery?.Weather.Changed ?? false;
|
||||
}
|
||||
|
||||
private static int FogFromSlider(double position)
|
||||
{
|
||||
double metres = SceneryWeather.FogMin *
|
||||
System.Math.Pow((double)SceneryWeather.FogMax / SceneryWeather.FogMin, position / 1000.0);
|
||||
int step = metres < 100 ? 5 : metres < 1000 ? 10 : 100;
|
||||
return System.Math.Clamp((int)(System.Math.Round(metres / step) * step),
|
||||
SceneryWeather.FogMin, SceneryWeather.FogMax);
|
||||
}
|
||||
|
||||
private static double SliderFromFog(int metres)
|
||||
{
|
||||
double clamped = System.Math.Clamp(metres, SceneryWeather.FogMin, SceneryWeather.FogMax);
|
||||
return 1000.0 * System.Math.Log(clamped / SceneryWeather.FogMin) /
|
||||
System.Math.Log((double)SceneryWeather.FogMax / SceneryWeather.FogMin);
|
||||
}
|
||||
|
||||
private static string FormatFog(int metres) =>
|
||||
metres < 1000
|
||||
? $"{metres} m"
|
||||
: string.Format(LanguageCulture, "{0:0.#} km", metres / 1000.0);
|
||||
|
||||
private static CultureInfo LanguageCulture
|
||||
{
|
||||
@@ -644,6 +594,12 @@ public partial class Scenarios : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Day-of-year arithmetic for the weather tab's date and season pickers.</summary>
|
||||
private static SeasonDates Seasons => new(AppServices.Current.Clock);
|
||||
|
||||
/// <summary>Fog read-out in the language the user is reading the rest in.</summary>
|
||||
private static string FogScale2(int metres) => FogScale.Format(metres, LanguageCulture);
|
||||
|
||||
private const string RandomTag = "random";
|
||||
|
||||
private static void SelectByTag(ComboBox combo, string tag, string? excludeFromNearest = null)
|
||||
@@ -714,7 +670,7 @@ public partial class Scenarios : UserControl
|
||||
{
|
||||
bool prev = _loadingWeather;
|
||||
_loadingWeather = true;
|
||||
weatherDate.SelectedDate = DateOfDayOfYear(0);
|
||||
weatherDate.SelectedDate = Seasons.DateOf(0);
|
||||
_loadingWeather = prev;
|
||||
}
|
||||
}
|
||||
@@ -735,16 +691,8 @@ public partial class Scenarios : UserControl
|
||||
CaptureWeather();
|
||||
}
|
||||
|
||||
private static DateTimeOffset DateOfDayOfYear(int day)
|
||||
{
|
||||
int year = DateTime.Now.Year;
|
||||
int last = DateTime.IsLeapYear(year) ? 366 : 365;
|
||||
int d = System.Math.Clamp(day <= 0 ? DateTime.Now.DayOfYear : day, 1, last);
|
||||
return new DateTimeOffset(new DateTime(year, 1, 1).AddDays(d - 1));
|
||||
}
|
||||
|
||||
private int SelectedDayOfYear() =>
|
||||
weatherDate.SelectedDate is { } date ? date.DayOfYear : DateTime.Now.DayOfYear;
|
||||
weatherDate.SelectedDate is { } date ? date.DayOfYear : Seasons.Today;
|
||||
|
||||
private void WeatherSeason_OnChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
@@ -761,7 +709,7 @@ public partial class Scenarios : UserControl
|
||||
{
|
||||
ApplyTodayPreview(false);
|
||||
_loadingWeather = true;
|
||||
weatherDate.SelectedDate = DateOfDayOfYear(day);
|
||||
weatherDate.SelectedDate = Seasons.DateOf(day);
|
||||
_loadingWeather = false;
|
||||
}
|
||||
}
|
||||
@@ -770,7 +718,7 @@ public partial class Scenarios : UserControl
|
||||
|
||||
private void ShowTimetable(Scenery scenery, Trainset trainset)
|
||||
{
|
||||
string? path = ResolveTimetablePath(scenery, trainset.Name);
|
||||
string? path = AppServices.Current.Timetables.Resolve(scenery, trainset.Name);
|
||||
if (path is null)
|
||||
{
|
||||
timetableContent.Text = App.Loc["NoTimetable"];
|
||||
@@ -801,26 +749,6 @@ public partial class Scenarios : UserControl
|
||||
tabs.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
private static string? ResolveTimetablePath(Scenery scenery, string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name) ||
|
||||
name.Equals("none", System.StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
string scnDir = Path.GetDirectoryName(scenery.Path) ?? ".";
|
||||
string root = Path.GetDirectoryName(scnDir) ?? ".";
|
||||
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(root, "timetables", name + ".txt"),
|
||||
Path.Combine(scnDir, name + ".txt"),
|
||||
Path.Combine(root, "scenario", name + ".txt"),
|
||||
Path.Combine(root, "timetables", name),
|
||||
Path.Combine(scnDir, name),
|
||||
};
|
||||
return candidates.FirstOrDefault(File.Exists);
|
||||
}
|
||||
|
||||
private const double ConsistDragThreshold = 6;
|
||||
|
||||
private Trainset? _stripTrainset;
|
||||
|
||||
Reference in New Issue
Block a user