mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-10-16 23:49:19 +02:00
Make Scenery a domain aggregate with no I/O
Parsing and loading move to Infrastructure/Sceneries. Weather becomes a value object owning the "does this differ from the file" rule.
This commit is contained in:
22
StarterNG.Tests/Fakes/StubRandom.cs
Normal file
22
StarterNG.Tests/Fakes/StubRandom.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using StarterNG.Application.Abstractions;
|
||||
|
||||
namespace StarterNG.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Randomness pinned to the low end of every range, so weather that varies in
|
||||
/// production is deterministic in tests.
|
||||
/// </summary>
|
||||
public sealed class StubRandom : IRandomSource
|
||||
{
|
||||
private readonly int _fixedValue;
|
||||
|
||||
public StubRandom(int fixedValue = 0)
|
||||
{
|
||||
_fixedValue = fixedValue;
|
||||
}
|
||||
|
||||
public int Next(int minInclusive, int maxExclusive) =>
|
||||
Math.Clamp(minInclusive + _fixedValue, minInclusive, Math.Max(minInclusive, maxExclusive - 1));
|
||||
|
||||
public double NextDouble() => 0.0;
|
||||
}
|
||||
146
StarterNG.Tests/SceneryExportTests.cs
Normal file
146
StarterNG.Tests/SceneryExportTests.cs
Normal file
@@ -0,0 +1,146 @@
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class SceneryExportTests
|
||||
{
|
||||
private static SceneryParser Parser() =>
|
||||
new(new FixedClock(new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom());
|
||||
|
||||
[Fact]
|
||||
public void An_untouched_scenery_exports_its_trainsets_back_verbatim()
|
||||
{
|
||||
const string scn = """
|
||||
//$n Test
|
||||
trainset pociag tor_1 10 0
|
||||
node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0
|
||||
endtrainset
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||
|
||||
Assert.DoesNotContain("{{0}}", exported);
|
||||
Assert.Contains("trainset pociag tor_1", exported);
|
||||
Assert.Contains("endtrainset", exported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selected_optional_includes_are_written_back_before_FirstInit()
|
||||
{
|
||||
const string scn = """
|
||||
include zima.inc end //$optional 1, Zima
|
||||
x
|
||||
endoptional
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||
|
||||
Assert.Contains("include zima.inc end", exported);
|
||||
Assert.True(exported.IndexOf("include zima.inc", StringComparison.Ordinal) <
|
||||
exported.IndexOf("FirstInit", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_deselected_optional_include_stays_out()
|
||||
{
|
||||
const string scn = """
|
||||
include zima.inc end //$optional 1, Zima
|
||||
x
|
||||
endoptional
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
scenery.Includes[0].Selected = false;
|
||||
|
||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||
|
||||
Assert.DoesNotContain("include zima.inc", exported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_kind_two_include_is_written_only_without_a_companion_timetable()
|
||||
{
|
||||
const string scn = """
|
||||
include rozklad.inc end //$optional 2, Rozkład
|
||||
x
|
||||
endoptional
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
scenery.HasCompanionTimetable = false;
|
||||
Assert.Contains("include rozklad.inc",
|
||||
scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()));
|
||||
|
||||
scenery.HasCompanionTimetable = true;
|
||||
Assert.DoesNotContain("include rozklad.inc",
|
||||
scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Edited_weather_replaces_the_authored_config_block()
|
||||
{
|
||||
const string scn = """
|
||||
config
|
||||
movelight 100
|
||||
scenario.weather.temperature 5
|
||||
eventlauncher.range 200
|
||||
endconfig
|
||||
atmo 0 0 0 10 2000 0 0 0 0 endatmo
|
||||
time 8:30 0 0 endtime
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
scenery.Weather.Day = 200;
|
||||
scenery.Weather.Temperature = 25;
|
||||
scenery.Weather.Dirty = true;
|
||||
|
||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||
|
||||
Assert.Contains("movelight 200", exported);
|
||||
Assert.Contains("scenario.weather.temperature 25", exported);
|
||||
Assert.DoesNotContain("movelight 100", exported);
|
||||
// Config the scenery author wrote and the starter does not manage survives.
|
||||
Assert.Contains("eventlauncher.range 200", exported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decor_trainsets_can_be_dropped_from_the_export()
|
||||
{
|
||||
const string scn = """
|
||||
//$n Test
|
||||
trainset ai_1 tor_1 10 0 //$decor
|
||||
node -1 -1 ai-1 dynamic PKP/EP07 ep07 ep07.mmd 0 nobody 0
|
||||
endtrainset
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
Assert.True(scenery.Trainsets[0].Decor);
|
||||
Assert.DoesNotContain("trainset ai_1",
|
||||
scenery.BuildExportContent(skipDecorTrainsets: true, new StubRandom()));
|
||||
Assert.Contains("trainset ai_1",
|
||||
scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loose_vehicles_are_appended_as_nodes_again()
|
||||
{
|
||||
const string scn = """
|
||||
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
|
||||
FirstInit
|
||||
""";
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
|
||||
|
||||
Assert.Contains("wagon_1", exported);
|
||||
Assert.Contains("enddynamic", exported);
|
||||
}
|
||||
}
|
||||
154
StarterNG.Tests/SceneryParserTests.cs
Normal file
154
StarterNG.Tests/SceneryParserTests.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class SceneryParserTests
|
||||
{
|
||||
private static SceneryParser Parser(DateTime? now = null) =>
|
||||
new(new FixedClock(now ?? new DateTime(2026, 5, 4, 9, 15, 0)), new StubRandom());
|
||||
|
||||
[Fact]
|
||||
public void Directives_become_the_scenery_headline()
|
||||
{
|
||||
const string scn = """
|
||||
//$l Śląsk
|
||||
//$n Osobowy do Katowic
|
||||
//$d Pierwszy opis
|
||||
//$d drugi wiersz
|
||||
//$i images/kato.jpg
|
||||
""";
|
||||
|
||||
var scenery = Parser().Parse("scenery/kato.scn", scn);
|
||||
|
||||
Assert.Equal("Śląsk", scenery.Group);
|
||||
Assert.Equal("Osobowy do Katowic", scenery.Name);
|
||||
Assert.Equal("Pierwszy opis\ndrugi wiersz", scenery.Description);
|
||||
Assert.Equal("images/kato.jpg", scenery.ImageName);
|
||||
Assert.False(scenery.Archival);
|
||||
Assert.Equal("kato", scenery.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_a_directive_marks_a_scenery_archival()
|
||||
{
|
||||
var scenery = Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n");
|
||||
|
||||
Assert.True(scenery.Archival);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attachments_take_a_label_from_the_rest_of_the_line()
|
||||
{
|
||||
const string scn = """
|
||||
//$f 01 doc/rozklad.pdf Rozkład jazdy
|
||||
//$f doc/mapa.png Mapa
|
||||
//$f doc/notes.txt
|
||||
""";
|
||||
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
Assert.Collection(scenery.Attachments,
|
||||
first =>
|
||||
{
|
||||
Assert.Equal("doc/rozklad.pdf", first.FilePath);
|
||||
Assert.Equal("Rozkład jazdy", first.Label);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal("doc/mapa.png", second.FilePath);
|
||||
Assert.Equal("Mapa", second.Label);
|
||||
},
|
||||
third =>
|
||||
{
|
||||
Assert.Equal("doc/notes.txt", third.FilePath);
|
||||
Assert.Equal("notes.txt", third.Label);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Optional_includes_are_lifted_out_of_the_template()
|
||||
{
|
||||
const string scn = """
|
||||
include zima.inc end //$optional 1, Sceneria zimowa
|
||||
some content
|
||||
endoptional
|
||||
FirstInit
|
||||
""";
|
||||
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
var include = Assert.Single(scenery.Includes);
|
||||
Assert.Equal("zima.inc", include.FilePath);
|
||||
Assert.Equal("Sceneria zimowa", include.Desc);
|
||||
Assert.Equal(1, include.Kind);
|
||||
Assert.True(include.Selected);
|
||||
Assert.DoesNotContain("zima.inc", scenery.Template);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Includes_of_kind_two_are_off_until_export_decides()
|
||||
{
|
||||
const string scn = """
|
||||
include rozklad.inc end //$optional 2, Rozkład wbudowany
|
||||
x
|
||||
endoptional
|
||||
""";
|
||||
|
||||
var include = Assert.Single(Parser().Parse("scenery/x.scn", scn).Includes);
|
||||
|
||||
Assert.Equal(2, include.Kind);
|
||||
Assert.False(include.Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trainsets_are_parsed_and_replaced_by_placeholders()
|
||||
{
|
||||
const string scn = """
|
||||
//$n Test
|
||||
trainset pociag tor_1 10 0
|
||||
node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0
|
||||
endtrainset
|
||||
FirstInit
|
||||
""";
|
||||
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
var trainset = Assert.Single(scenery.Trainsets);
|
||||
Assert.True(trainset.Parsed);
|
||||
Assert.Equal("pociag", trainset.Name);
|
||||
Assert.Equal("tor_1", trainset.Track);
|
||||
Assert.Contains("{{0}}", scenery.Template);
|
||||
Assert.DoesNotContain("endtrainset", scenery.Template);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vehicles_outside_a_trainset_are_collected_as_loose()
|
||||
{
|
||||
const string scn = """
|
||||
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
|
||||
FirstInit
|
||||
""";
|
||||
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
var vehicle = Assert.Single(scenery.LooseVehicles);
|
||||
Assert.Equal("wagon_1", vehicle.Name);
|
||||
Assert.Equal("PKP/111A", vehicle.DataFolder);
|
||||
Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames);
|
||||
Assert.DoesNotContain("enddynamic", scenery.Template);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_malformed_loose_vehicle_is_reported_and_left_in_place()
|
||||
{
|
||||
const string scn = "node x -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic\n";
|
||||
|
||||
var scenery = Parser().Parse("scenery/x.scn", scn);
|
||||
|
||||
Assert.Empty(scenery.LooseVehicles);
|
||||
Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames);
|
||||
Assert.Contains(scenery.Faults, fault => fault.Contains("wagon_1"));
|
||||
Assert.Contains("enddynamic", scenery.Template);
|
||||
}
|
||||
}
|
||||
101
StarterNG.Tests/SceneryRepositoryTests.cs
Normal file
101
StarterNG.Tests/SceneryRepositoryTests.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System.Text;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class SceneryRepositoryTests
|
||||
{
|
||||
private static SceneryRepository Build(InMemoryFileSystem files, out FileDiagnosticsLog log)
|
||||
{
|
||||
var paths = new GamePaths("game");
|
||||
var clock = new FixedClock(new DateTime(2026, 1, 1));
|
||||
log = new FileDiagnosticsLog(files, clock, paths);
|
||||
return new SceneryRepository(files, paths, log, new SceneryParser(clock, new StubRandom()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_scn_in_the_scenery_folder_is_loaded()
|
||||
{
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice")
|
||||
.WithFile(Path.Combine("game", "scenery", "gdansk.scn"), "//$n Gdańsk");
|
||||
|
||||
var loaded = Build(files, out _).LoadAll();
|
||||
|
||||
Assert.Equal(2, loaded.Count);
|
||||
Assert.Contains(loaded, scenery => scenery.Name == "Katowice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Authoring_scratch_files_and_other_extensions_are_skipped()
|
||||
{
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(Path.Combine("game", "scenery", "$wip.scn"), "//$n Robocza")
|
||||
.WithFile(Path.Combine("game", "scenery", "kato.inc"), "//$n Include")
|
||||
.WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice");
|
||||
|
||||
var loaded = Build(files, out _).LoadAll();
|
||||
|
||||
Assert.Equal("Katowice", Assert.Single(loaded).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_companion_sbt_is_noticed_when_the_scenery_is_loaded()
|
||||
{
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice")
|
||||
.WithFile(Path.Combine("game", "scenery", "kato.sbt"), "timetable");
|
||||
|
||||
var scenery = Assert.Single(Build(files, out _).LoadAll());
|
||||
|
||||
Assert.True(scenery.HasCompanionTimetable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_scenery_loads_as_null_rather_than_throwing()
|
||||
{
|
||||
Assert.Null(Build(new InMemoryFileSystem(), out _).Load(Path.Combine("game", "scenery", "gone.scn")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Progress_is_reported_once_per_file()
|
||||
{
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(Path.Combine("game", "scenery", "a.scn"), "//$n A")
|
||||
.WithFile(Path.Combine("game", "scenery", "b.scn"), "//$n B");
|
||||
var steps = new List<int>();
|
||||
|
||||
Build(files, out _).LoadAll(new Progress<StarterNG.Application.Abstractions.SceneryLoadProgress>(
|
||||
step => { lock (steps) steps.Add(step.Loaded); }));
|
||||
|
||||
// Progress<T> posts asynchronously; give it a moment to drain.
|
||||
SpinWait.SpinUntil(() => { lock (steps) return steps.Count == 2; }, TimeSpan.FromSeconds(2));
|
||||
lock (steps)
|
||||
Assert.Equal(new[] { 1, 2 }, steps.OrderBy(value => value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_preview_image_is_found_next_to_the_scenery()
|
||||
{
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$i kato.jpg")
|
||||
.WithFile(Path.Combine("game", "scenery", "images", "kato.jpg"), "binary");
|
||||
var scenery = Assert.Single(Build(files, out _).LoadAll());
|
||||
|
||||
string? image = new SceneryImageLocator(files).Resolve(scenery);
|
||||
|
||||
Assert.Equal(Path.Combine("game", "scenery", "images", "kato.jpg"), image);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_scenery_without_an_image_directive_resolves_to_nothing()
|
||||
{
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice");
|
||||
var scenery = Assert.Single(Build(files, out _).LoadAll());
|
||||
|
||||
Assert.Null(new SceneryImageLocator(files).Resolve(scenery));
|
||||
}
|
||||
}
|
||||
87
StarterNG.Tests/SceneryWeatherTests.cs
Normal file
87
StarterNG.Tests/SceneryWeatherTests.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using StarterNG.Domain.Sceneries;
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class SceneryWeatherTests
|
||||
{
|
||||
private static SceneryParser Parser(DateTime now) => new(new FixedClock(now), new StubRandom());
|
||||
|
||||
[Fact]
|
||||
public void Authored_weather_is_read_from_the_config_block()
|
||||
{
|
||||
const string scn = """
|
||||
config
|
||||
movelight 172
|
||||
scenario.weather.temperature 21.5
|
||||
scenario.time.override 6:05
|
||||
endconfig
|
||||
atmo 0 0 0 50 900 0 0 0 0.7 endatmo
|
||||
time 8:30 0 0 endtime
|
||||
""";
|
||||
|
||||
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather;
|
||||
|
||||
Assert.True(weather.IsAuthored);
|
||||
Assert.Equal(172, weather.Day);
|
||||
Assert.Equal(21.5, weather.Temperature);
|
||||
Assert.Equal("06:05", weather.ScenarioTimeOverride);
|
||||
Assert.Equal("08:30", weather.Time);
|
||||
Assert.Equal(50, weather.FogStart);
|
||||
Assert.Equal(0.7, weather.Overcast);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_scenery_without_a_time_starts_at_the_players_clock()
|
||||
{
|
||||
var weather = Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", "//$n Test").Weather;
|
||||
|
||||
Assert.Equal("17:42", weather.ScenarioTimeOverride);
|
||||
Assert.False(weather.IsAuthored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Freshly_parsed_weather_counts_as_unchanged()
|
||||
{
|
||||
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
|
||||
|
||||
Assert.False(weather.Changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Editing_then_restoring_returns_to_the_authored_values()
|
||||
{
|
||||
var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
|
||||
|
||||
weather.Day = 5;
|
||||
weather.Temperature = -12;
|
||||
Assert.True(weather.Changed);
|
||||
|
||||
weather.Restore();
|
||||
|
||||
Assert.False(weather.Changed);
|
||||
Assert.Equal(100, weather.Day);
|
||||
Assert.True(weather.Dirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_fog_range_is_clamped_into_the_supported_span()
|
||||
{
|
||||
var weather = new SceneryWeather();
|
||||
|
||||
weather.SetFogRange(start: 1, end: 999999, random: new StubRandom());
|
||||
|
||||
Assert.Equal(SceneryWeather.FogMin, weather.FogStart);
|
||||
Assert.InRange(weather.FogEnd, SceneryWeather.FogMin, SceneryWeather.FogMax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Random_overcast_draws_from_the_presets_rather_than_the_stored_value()
|
||||
{
|
||||
var weather = new SceneryWeather { Overcast = 0.42, OvercastRandom = true };
|
||||
|
||||
Assert.Equal(-1.5, weather.EffectiveOvercast(new StubRandom()));
|
||||
Assert.Equal(0.42, new SceneryWeather { Overcast = 0.42 }.EffectiveOvercast(new StubRandom()));
|
||||
}
|
||||
}
|
||||
15
StarterNG/Application/Abstractions/IRandomSource.cs
Normal file
15
StarterNG/Application/Abstractions/IRandomSource.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace StarterNG.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Port over randomness. Scenery weather deliberately varies between runs (fog
|
||||
/// distance, overcast presets, texture randomiser); routing it through a port
|
||||
/// keeps those rules assertable instead of flaky.
|
||||
/// </summary>
|
||||
public interface IRandomSource
|
||||
{
|
||||
/// <summary>Inclusive lower bound, exclusive upper bound.</summary>
|
||||
int Next(int minInclusive, int maxExclusive);
|
||||
|
||||
/// <summary>Uniform value in [0, 1).</summary>
|
||||
double NextDouble();
|
||||
}
|
||||
24
StarterNG/Application/Abstractions/ISceneryRepository.cs
Normal file
24
StarterNG/Application/Abstractions/ISceneryRepository.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Application.Abstractions;
|
||||
|
||||
/// <summary>How far a scenery sweep has got, for the splash screen.</summary>
|
||||
public readonly record struct SceneryLoadProgress(int Loaded, int Total, string FileName);
|
||||
|
||||
/// <summary>
|
||||
/// Reads the scenarios of an installation. The only route from a .scn on disk to
|
||||
/// a <see cref="Scenery"/>; nothing else constructs one from a path.
|
||||
/// </summary>
|
||||
public interface ISceneryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Every scenario in the installation, in no particular order. Files that
|
||||
/// fail to parse are logged and skipped rather than failing the load.
|
||||
/// </summary>
|
||||
IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null);
|
||||
|
||||
/// <summary>Reloads a single scenario; null when it is gone or unreadable.</summary>
|
||||
Scenery? Load(string path);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
using StarterNG.Infrastructure.Sceneries;
|
||||
using StarterNG.Services;
|
||||
|
||||
namespace StarterNG.Application;
|
||||
@@ -33,8 +34,11 @@ public sealed class AppServices
|
||||
|
||||
var log = new FileDiagnosticsLog(files, clock, paths);
|
||||
Log = log;
|
||||
Random = new SystemRandomSource();
|
||||
Processes = new SystemProcessLauncher(log);
|
||||
Localization = new LocalizationService(log);
|
||||
Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random));
|
||||
SceneryImages = new SceneryImageLocator(files);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -66,6 +70,12 @@ public sealed class AppServices
|
||||
|
||||
public IProcessLauncher Processes { get; }
|
||||
|
||||
public IRandomSource Random { get; }
|
||||
|
||||
public ISceneryRepository Sceneries { get; }
|
||||
|
||||
public SceneryImageLocator SceneryImages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Exposed as the concrete service rather than <see cref="ILocalizedStrings"/>
|
||||
/// because the XAML binds to it as a change-notifying source; code should take
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
|
||||
@@ -26,17 +29,12 @@ public sealed class GameData
|
||||
public bool Loaded { get; private set; }
|
||||
|
||||
public void Load(IProgress<LoadStatus>? progress = null,
|
||||
string sceneryDir = "scenery/",
|
||||
string miniDir = "textures/mini/",
|
||||
string dynamicRoot = "dynamic")
|
||||
{
|
||||
if (Loaded)
|
||||
return;
|
||||
|
||||
var scnFiles = EnumerateScenery(sceneryDir);
|
||||
int total = Math.Max(1, scnFiles.Count);
|
||||
int done = 0;
|
||||
|
||||
progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt"));
|
||||
int vehicles = Vehicles.LoadFromTexturesTxt(dynamicRoot);
|
||||
if (vehicles == 0)
|
||||
@@ -46,61 +44,19 @@ public sealed class GameData
|
||||
() => VehicleDatabase.PreloadMiniIndex(miniDir),
|
||||
() => Physics.PreloadIndex());
|
||||
|
||||
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 (Exception ex)
|
||||
{
|
||||
StarterNG.Infrastructure.Diagnostics.Log($"scenery/{Path.GetFileName(file)}", ex);
|
||||
}
|
||||
});
|
||||
done = doneCount;
|
||||
var sceneryProgress = progress is null
|
||||
? null
|
||||
: new Progress<SceneryLoadProgress>(step => progress.Report(
|
||||
new LoadStatus((double)step.Loaded / step.Total, LoadPhase.Sceneries, step.FileName)));
|
||||
ReloadSceneries(sceneryProgress);
|
||||
|
||||
progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null));
|
||||
Loaded = true;
|
||||
}
|
||||
|
||||
public void ReloadSceneries(string sceneryDir = "scenery/")
|
||||
public void ReloadSceneries(IProgress<SceneryLoadProgress>? progress = null)
|
||||
{
|
||||
Sceneries.Clear();
|
||||
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 (Exception ex)
|
||||
{
|
||||
StarterNG.Infrastructure.Diagnostics.Log($"scenery/{Path.GetFileName(file)}", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static List<string> EnumerateScenery(string sceneryDir)
|
||||
{
|
||||
if (!Directory.Exists(sceneryDir))
|
||||
return new List<string>();
|
||||
|
||||
return Directory.GetFiles(sceneryDir, "*.scn")
|
||||
.Where(p => !Path.GetFileName(p).StartsWith("$"))
|
||||
.ToList();
|
||||
Sceneries.AddRange(AppServices.Current.Sceneries.LoadAll(progress));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using StarterNG.Domain;
|
||||
|
||||
namespace StarterNG.Classes;
|
||||
|
||||
public sealed class SceneryAttachment
|
||||
{
|
||||
public string FilePath = "";
|
||||
public string Label = "";
|
||||
}
|
||||
|
||||
public sealed class SceneryInclude
|
||||
{
|
||||
public string FilePath = "";
|
||||
public string Desc = "";
|
||||
public int Kind;
|
||||
public bool Selected;
|
||||
}
|
||||
|
||||
public class Scenery
|
||||
{
|
||||
public List<string> Lines;
|
||||
public List<Trainset> Trainsets;
|
||||
public List<SceneryAttachment> Attachments = new();
|
||||
public List<SceneryInclude> Includes = new();
|
||||
|
||||
public List<string> LooseVehicleNames = new();
|
||||
|
||||
public List<Dynamic> LooseVehicles = new();
|
||||
|
||||
public List<string> Faults = new();
|
||||
public string Group;
|
||||
public string Path;
|
||||
|
||||
public string Name;
|
||||
public string Description;
|
||||
public string ImageName;
|
||||
|
||||
public bool Archival;
|
||||
|
||||
public string Time = "10:30";
|
||||
public string ScenarioTimeOverride = "10:30";
|
||||
public int Day = 0;
|
||||
public double Temperature = 15;
|
||||
public int FogStart = 10;
|
||||
public int FogEnd = 2000;
|
||||
|
||||
public const int FogMin = 10;
|
||||
public const int FogMax = 10000;
|
||||
public double Overcast = 0;
|
||||
public bool OvercastRandom;
|
||||
|
||||
public bool HasWeather;
|
||||
|
||||
public bool WeatherDirty;
|
||||
|
||||
private string _origTime = "10:30";
|
||||
private string _origOverride = "10:30";
|
||||
private int _origDay;
|
||||
private double _origTemperature = 15;
|
||||
private int _origFogEnd = 2000;
|
||||
private double _origOvercast;
|
||||
private bool _origOvercastRandom;
|
||||
|
||||
private readonly string _template;
|
||||
|
||||
public string DisplayName
|
||||
{
|
||||
get
|
||||
{
|
||||
return System.IO.Path.GetFileNameWithoutExtension(Path);
|
||||
}
|
||||
}
|
||||
|
||||
public string LocalizedDescription =>
|
||||
string.IsNullOrEmpty(Description) ? "" :
|
||||
string.Join("\n", Description.Split('\n').Select(SceneryI18n.T));
|
||||
|
||||
private string _imagePath;
|
||||
private bool _imagePathResolved;
|
||||
|
||||
public Scenery(string path)
|
||||
{
|
||||
this.Path = path;
|
||||
Trainsets = new List<Trainset>();
|
||||
if (!File.Exists(path))
|
||||
throw new FileNotFoundException(path);
|
||||
var encoding = Encoding.GetEncoding(1250);
|
||||
string content = File.ReadAllText(path, encoding);
|
||||
|
||||
this.Group = MatchDirective(content, "l");
|
||||
this.Name = MatchDirective(content, "n");
|
||||
|
||||
this.Description = MatchAllDirectives(content, "d");
|
||||
this.ImageName = MatchDirective(content, "i");
|
||||
|
||||
this.Archival = MatchDirective(content, "a") != null;
|
||||
|
||||
ParseWeather(content);
|
||||
SnapshotWeather();
|
||||
|
||||
ParseAttachments(content);
|
||||
content = ParseAndStripOptionalIncludes(content);
|
||||
|
||||
List<string> trainsetEntries = new List<string>();
|
||||
Regex regex = new Regex(
|
||||
@"trainset\b[\s\S]*?\bendtrainset\b",
|
||||
RegexOptions.IgnoreCase
|
||||
);
|
||||
int idx = 0;
|
||||
content = regex.Replace(content, match =>
|
||||
{
|
||||
trainsetEntries.Add(match.Value);
|
||||
return $"{{{{{idx++}}}}}";
|
||||
});
|
||||
content = ExtractLooseVehicles(content);
|
||||
_template = content;
|
||||
|
||||
foreach (string trainsetEntry in trainsetEntries)
|
||||
{
|
||||
var ts = new Trainset(trainsetEntry);
|
||||
Trainsets.Add(ts);
|
||||
if (!ts.Parsed)
|
||||
Faults.Add($"# trainset parse fault: {ts.Name} ({ts.Track})");
|
||||
}
|
||||
}
|
||||
|
||||
private string ExtractLooseVehicles(string content)
|
||||
{
|
||||
LooseVehicles.Clear();
|
||||
LooseVehicleNames.Clear();
|
||||
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
||||
|
||||
return Regex.Replace(
|
||||
content,
|
||||
@"(?is)\bnode\s+(\S+)\s+(\S+)\s+(\S+)\s+dynamic\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)((?:\s+(?!enddynamic)\S+)*)\s*enddynamic\b",
|
||||
m =>
|
||||
{
|
||||
string name = m.Groups[3].Value;
|
||||
if (name.StartsWith("{{", StringComparison.Ordinal))
|
||||
return m.Value;
|
||||
try
|
||||
{
|
||||
var d = new Dynamic
|
||||
{
|
||||
RangeMax = float.Parse(m.Groups[1].Value, inv),
|
||||
RangeMin = float.Parse(m.Groups[2].Value, inv),
|
||||
Name = name,
|
||||
DataFolder = m.Groups[4].Value,
|
||||
SkinFile = Dynamic.StripSkinExtension(m.Groups[5].Value),
|
||||
MmdFile = m.Groups[6].Value,
|
||||
PathName = m.Groups[7].Value,
|
||||
Offset = float.Parse(m.Groups[8].Value, inv),
|
||||
DriverType = m.Groups[9].Value.ToLowerInvariant() switch
|
||||
{
|
||||
"headdriver" => eDriverType.Headdriver,
|
||||
"reardriver" => eDriverType.Reardriver,
|
||||
"passenger" => eDriverType.Passenger,
|
||||
_ => eDriverType.Nobody
|
||||
},
|
||||
HasVelocity = true,
|
||||
Velocity = float.Parse(m.Groups[10].Value, inv)
|
||||
};
|
||||
var trailing = m.Groups[11].Value
|
||||
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
|
||||
.ToList();
|
||||
d.ReadTrailing(trailing);
|
||||
LooseVehicles.Add(d);
|
||||
LooseVehicleNames.Add(d.Name);
|
||||
return "\n";
|
||||
}
|
||||
catch
|
||||
{
|
||||
Faults.Add($"# loose dynamic parse fault: {name}");
|
||||
LooseVehicleNames.Add(name);
|
||||
return m.Value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public string BuildExportContent(bool skipDecorTrainsets = false)
|
||||
{
|
||||
string result = _template;
|
||||
|
||||
if (WeatherDirty)
|
||||
result = RewriteWeather(result);
|
||||
|
||||
result = InjectIncludes(result);
|
||||
|
||||
for (int i = 0; i < Trainsets.Count; i++)
|
||||
{
|
||||
string entry = skipDecorTrainsets && Trainsets[i].Decor
|
||||
? string.Empty
|
||||
: Trainsets[i].ToSceneryEntry();
|
||||
result = result.Replace("{{" + i + "}}", entry);
|
||||
}
|
||||
|
||||
if (LooseVehicles.Count > 0)
|
||||
result = AppendLooseVehicles(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string AppendLooseVehicles(string text)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var v in LooseVehicles)
|
||||
sb.Append(v.ToLooseNode());
|
||||
if (sb.Length == 0) return text;
|
||||
|
||||
var fi = Regex.Match(text, @"\bFirstInit\b", RegexOptions.IgnoreCase);
|
||||
if (fi.Success)
|
||||
return text[..fi.Index] + sb + text[fi.Index..];
|
||||
return text + sb;
|
||||
}
|
||||
|
||||
private void ParseAttachments(string content)
|
||||
{
|
||||
Attachments.Clear();
|
||||
foreach (Match m in Regex.Matches(content, @"^//\$f\b[ \t]*([^\r\n]*)", RegexOptions.Multiline))
|
||||
{
|
||||
string rest = m.Groups[1].Value.Trim();
|
||||
if (rest.Length == 0) continue;
|
||||
string[] parts = rest.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (parts.Length >= 3 && parts[0].Length <= 2)
|
||||
Attachments.Add(new SceneryAttachment
|
||||
{
|
||||
FilePath = parts[1],
|
||||
Label = string.Join(" ", parts.Skip(2))
|
||||
});
|
||||
else if (parts.Length >= 2)
|
||||
Attachments.Add(new SceneryAttachment
|
||||
{
|
||||
FilePath = parts[0],
|
||||
Label = string.Join(" ", parts.Skip(1))
|
||||
});
|
||||
else
|
||||
Attachments.Add(new SceneryAttachment
|
||||
{
|
||||
FilePath = parts[0],
|
||||
Label = System.IO.Path.GetFileName(parts[0])
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private string ParseAndStripOptionalIncludes(string content)
|
||||
{
|
||||
Includes.Clear();
|
||||
|
||||
if (content.IndexOf("$optional", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
return content;
|
||||
|
||||
return Regex.Replace(
|
||||
content,
|
||||
@"include\s+(\S+(?:\s+\S+)*?)\s+end\s*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b",
|
||||
m =>
|
||||
{
|
||||
string incPath = Regex.Replace(m.Groups[1].Value.Trim(), @"\s+", " ");
|
||||
string raw = m.Groups[2].Value.Trim().TrimStart(',', ' ');
|
||||
string[] par = raw.Split(new[] { ',', '|', ';' },
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
int kind = 1;
|
||||
string desc = incPath;
|
||||
if (par.Length >= 1 && int.TryParse(par[0], out int k))
|
||||
kind = k;
|
||||
if (par.Length >= 2)
|
||||
desc = par[1];
|
||||
|
||||
Includes.Add(new SceneryInclude
|
||||
{
|
||||
FilePath = incPath,
|
||||
Desc = desc,
|
||||
Kind = kind,
|
||||
Selected = kind == 1
|
||||
});
|
||||
return " ";
|
||||
},
|
||||
RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
private string InjectIncludes(string text)
|
||||
{
|
||||
if (Includes.Count == 0)
|
||||
return text;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
string sbt = System.IO.Path.ChangeExtension(Path, ".sbt");
|
||||
bool hasSbt = File.Exists(sbt);
|
||||
|
||||
foreach (var inc in Includes)
|
||||
{
|
||||
if (inc.Kind == 2)
|
||||
{
|
||||
if (!hasSbt)
|
||||
sb.Append("include ").Append(inc.FilePath).Append(" end\r\n");
|
||||
continue;
|
||||
}
|
||||
if (inc.Selected)
|
||||
sb.Append("include ").Append(inc.FilePath).Append(" end\r\n");
|
||||
}
|
||||
|
||||
if (sb.Length == 0)
|
||||
return text;
|
||||
|
||||
var fi = Regex.Match(text, @"\bFirstInit\b", RegexOptions.IgnoreCase);
|
||||
if (fi.Success)
|
||||
return text[..fi.Index] + sb + text[fi.Index..];
|
||||
return sb + text;
|
||||
}
|
||||
|
||||
private void ParseWeather(string content)
|
||||
{
|
||||
|
||||
var time = Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b");
|
||||
if (time.Success)
|
||||
{
|
||||
Time = $"{time.Groups[1].Value.PadLeft(2, '0')}:{time.Groups[2].Value}";
|
||||
ScenarioTimeOverride = Time;
|
||||
}
|
||||
|
||||
var ovr = Regex.Match(content, @"(?i)scenario\.time\.override\s+(\d{1,2})[:.](\d{2})");
|
||||
if (ovr.Success)
|
||||
{
|
||||
ScenarioTimeOverride = $"{ovr.Groups[1].Value.PadLeft(2, '0')}:{ovr.Groups[2].Value}";
|
||||
HasWeather = true;
|
||||
}
|
||||
else if (!time.Success)
|
||||
{
|
||||
|
||||
var now = System.DateTime.Now;
|
||||
ScenarioTimeOverride = $"{now.Hour:D2}:{now.Minute:D2}";
|
||||
}
|
||||
|
||||
var move = Regex.Match(content, @"(?im)\bmovelight\s+(-?\d+)");
|
||||
if (move.Success && int.TryParse(move.Groups[1].Value, out int day))
|
||||
{
|
||||
Day = day;
|
||||
HasWeather = true;
|
||||
}
|
||||
|
||||
var temp = Regex.Match(content, @"(?i)scenario\.weather\.temperature\s+(-?\d+(?:\.\d+)?)");
|
||||
if (temp.Success &&
|
||||
double.TryParse(temp.Groups[1].Value, System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out double t))
|
||||
{
|
||||
Temperature = t;
|
||||
HasWeather = true;
|
||||
}
|
||||
|
||||
var atmo = Regex.Match(content, @"(?is)\batmo\b(.*?)\bendatmo\b");
|
||||
if (atmo.Success)
|
||||
{
|
||||
var nums = Regex.Matches(atmo.Groups[1].Value, @"-?\d+(?:\.\d+)?")
|
||||
.Select(m => m.Value).ToList();
|
||||
if (nums.Count >= 5 &&
|
||||
int.TryParse(nums[3], out int fogStart) &&
|
||||
int.TryParse(nums[4], out int fogEnd))
|
||||
{
|
||||
FogStart = Math.Clamp(fogStart, FogMin, FogMax);
|
||||
fogEnd = Math.Clamp(fogEnd, FogStart, FogMax);
|
||||
FogEnd = fogEnd > FogStart
|
||||
? Random.Shared.Next(FogStart, fogEnd + 1)
|
||||
: fogEnd;
|
||||
HasWeather = true;
|
||||
}
|
||||
if (nums.Count >= 6 &&
|
||||
double.TryParse(nums[^1], System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out double oc))
|
||||
Overcast = oc;
|
||||
}
|
||||
}
|
||||
|
||||
private void SnapshotWeather()
|
||||
{
|
||||
_origTime = Time;
|
||||
_origOverride = ScenarioTimeOverride;
|
||||
_origDay = Day;
|
||||
_origTemperature = Temperature;
|
||||
_origFogEnd = FogEnd;
|
||||
_origOvercast = Overcast;
|
||||
_origOvercastRandom = OvercastRandom;
|
||||
}
|
||||
|
||||
public bool WeatherChanged =>
|
||||
Day != _origDay ||
|
||||
ScenarioTimeOverride != _origOverride ||
|
||||
Math.Abs(Temperature - _origTemperature) > 0.001 ||
|
||||
FogEnd != _origFogEnd ||
|
||||
OvercastRandom != _origOvercastRandom ||
|
||||
(!OvercastRandom && Math.Abs(Overcast - _origOvercast) > 0.001);
|
||||
|
||||
public void RestoreWeather()
|
||||
{
|
||||
Time = _origTime;
|
||||
ScenarioTimeOverride = _origOverride;
|
||||
Day = _origDay;
|
||||
Temperature = _origTemperature;
|
||||
FogEnd = _origFogEnd;
|
||||
Overcast = _origOvercast;
|
||||
OvercastRandom = _origOvercastRandom;
|
||||
WeatherDirty = true;
|
||||
}
|
||||
|
||||
private static readonly double[] OvercastPresets = { -1.5, 0, 0.3, 0.7, 1, 1.1, 1.5 };
|
||||
|
||||
private double EffectiveOvercast() =>
|
||||
OvercastRandom ? OvercastPresets[Random.Shared.Next(OvercastPresets.Length)] : Overcast;
|
||||
|
||||
private string RewriteWeather(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
string s = text;
|
||||
|
||||
string carried = CarriedConfigLines(s);
|
||||
|
||||
s = Regex.Replace(s, @"(?is)\bconfig\b.*?\bendconfig\b", " ");
|
||||
s = Regex.Replace(s, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " ");
|
||||
s = Regex.Replace(s, @"(?is)\batmo\b.*?\bendatmo\b", " ");
|
||||
s = Regex.Replace(s, @"(?im)^[ \t]*movelight\s+\S+", " ");
|
||||
s = Regex.Replace(s, @"(?i)scenario\.weather\.temperature\s+\S+", " ");
|
||||
s = Regex.Replace(s, @"(?i)scenario\.time\.override\s+\S+", " ");
|
||||
|
||||
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
||||
|
||||
string config =
|
||||
"config\r\n" +
|
||||
$"movelight {Day}\r\n" +
|
||||
$"scenario.weather.temperature {Temperature.ToString(inv)}\r\n" +
|
||||
$"scenario.time.override {DelphiHourMinute(ScenarioTimeOverride)}\r\n" +
|
||||
carried +
|
||||
"endconfig\r\n" +
|
||||
$"atmo 0 0 0 {FogEnd} {FogEnd} 0 0 0 {EffectiveOvercast().ToString(inv)} endatmo\r\n" +
|
||||
$"time {DelphiHourMinute(Time)} 0 0 endtime\r\n";
|
||||
|
||||
return config + s;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DelphiHourMinute(string hhmm)
|
||||
{
|
||||
int colon = hhmm.IndexOf(':');
|
||||
if (colon <= 0) return hhmm;
|
||||
return hhmm[..colon].TrimStart('0') is { Length: > 0 } h
|
||||
? h + hhmm[colon..]
|
||||
: "0" + hhmm[colon..];
|
||||
}
|
||||
|
||||
private static readonly Regex ManagedConfigLine = new(
|
||||
@"^\s*(movelight|scenario\.weather\.temperature|scenario\.time\.override)\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static string CarriedConfigLines(string text)
|
||||
{
|
||||
var block = Regex.Match(text, @"(?is)\bconfig\b(.*?)\bendconfig\b");
|
||||
if (!block.Success)
|
||||
return "";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (string raw in block.Groups[1].Value.Split('\n'))
|
||||
{
|
||||
string line = raw.TrimEnd('\r').Trim();
|
||||
if (line.Length == 0 || ManagedConfigLine.IsMatch(line))
|
||||
continue;
|
||||
sb.Append(line).Append("\r\n");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string MatchAllDirectives(string content, string letter)
|
||||
{
|
||||
var matches = Regex.Matches(
|
||||
content,
|
||||
@"^//\$" + letter + @"\b[ \t]*([^\r\n]*)",
|
||||
RegexOptions.Multiline
|
||||
);
|
||||
if (matches.Count == 0)
|
||||
return null;
|
||||
return string.Join("\n", matches.Select(m => m.Groups[1].Value.TrimEnd()));
|
||||
}
|
||||
|
||||
private static string MatchDirective(string content, string letter)
|
||||
{
|
||||
var match = Regex.Match(
|
||||
content,
|
||||
@"^//\$" + letter + @"\b[ \t]*([^\r\n]*)",
|
||||
RegexOptions.Multiline
|
||||
);
|
||||
return match.Success ? match.Groups[1].Value.Trim() : null;
|
||||
}
|
||||
|
||||
public string ImagePath
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_imagePathResolved)
|
||||
return _imagePath;
|
||||
_imagePathResolved = true;
|
||||
_imagePath = ResolveImagePath();
|
||||
return _imagePath;
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveImagePath()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ImageName))
|
||||
return null;
|
||||
|
||||
string name = ImageName.Replace('\\', '/').Trim();
|
||||
string scnDir = System.IO.Path.GetDirectoryName(Path) ?? ".";
|
||||
string root = System.IO.Path.GetDirectoryName(scnDir) ?? ".";
|
||||
|
||||
var candidates = new List<string>
|
||||
{
|
||||
name,
|
||||
System.IO.Path.Combine(root, name),
|
||||
System.IO.Path.Combine(scnDir, name),
|
||||
System.IO.Path.Combine(scnDir, "images", name),
|
||||
System.IO.Path.Combine(root, "scenery", "images", name),
|
||||
};
|
||||
|
||||
foreach (string candidate in candidates)
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Domain;
|
||||
|
||||
|
||||
183
StarterNG/Domain/Sceneries/Scenery.cs
Normal file
183
StarterNG/Domain/Sceneries/Scenery.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
|
||||
namespace StarterNG.Domain.Sceneries;
|
||||
|
||||
/// <summary>A file the scenery offers to open alongside it (map, manual, ...).</summary>
|
||||
public sealed class SceneryAttachment
|
||||
{
|
||||
public string FilePath = "";
|
||||
public string Label = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <c>include</c> the scenery marks as optional, so the user can decide
|
||||
/// whether it ends up in the exported scenario.
|
||||
/// </summary>
|
||||
public sealed class SceneryInclude
|
||||
{
|
||||
public string FilePath = "";
|
||||
public string Desc = "";
|
||||
|
||||
/// <summary>1 = on by default, 2 = only when no companion timetable exists.</summary>
|
||||
public int Kind;
|
||||
|
||||
public bool Selected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A scenario the user can start: its consists, loose vehicles, optional
|
||||
/// includes and weather, plus the ability to render all of that back into a .scn
|
||||
/// the simulator can load.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The aggregate is pure: it never reads or writes files. Parsing lives in
|
||||
/// <c>Infrastructure.Sceneries.SceneryParser</c> and loading in
|
||||
/// <c>SceneryRepository</c>, which is what lets the export rules be tested from
|
||||
/// a string rather than an installation.
|
||||
///
|
||||
/// <see cref="Template"/> is the .scn text with every trainset replaced by a
|
||||
/// <c>{{index}}</c> placeholder and the optional includes stripped, so
|
||||
/// <see cref="BuildExportContent"/> only has to substitute rather than re-parse.
|
||||
/// </remarks>
|
||||
public sealed class Scenery
|
||||
{
|
||||
public Scenery(string path, string template)
|
||||
{
|
||||
Path = path;
|
||||
Template = template;
|
||||
}
|
||||
|
||||
/// <summary>Path of the .scn this was parsed from; the aggregate's identity.</summary>
|
||||
public string Path { get; }
|
||||
|
||||
/// <summary>The .scn body with trainset placeholders, used for export.</summary>
|
||||
public string Template { get; }
|
||||
|
||||
/// <summary>True when a companion .sbt timetable sits next to the .scn.</summary>
|
||||
public bool HasCompanionTimetable { get; set; }
|
||||
|
||||
public string? Group { get; set; }
|
||||
|
||||
public string? Name { get; set; }
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
public string? ImageName { get; set; }
|
||||
|
||||
/// <summary>Scenery kept for historical interest; hidden unless asked for.</summary>
|
||||
public bool Archival { get; set; }
|
||||
|
||||
public List<Trainset> Trainsets { get; } = new();
|
||||
|
||||
public List<SceneryAttachment> Attachments { get; } = new();
|
||||
|
||||
public List<SceneryInclude> Includes { get; } = new();
|
||||
|
||||
/// <summary>Vehicles placed directly as nodes rather than inside a trainset.</summary>
|
||||
public List<Dynamic> LooseVehicles { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Names of every loose vehicle, including those whose node failed to parse
|
||||
/// and therefore has no <see cref="Dynamic"/>.
|
||||
/// </summary>
|
||||
public List<string> LooseVehicleNames { get; } = new();
|
||||
|
||||
/// <summary>Parse problems worth showing on the scenario's fault list.</summary>
|
||||
public List<string> Faults { get; } = new();
|
||||
|
||||
public SceneryWeather Weather { get; } = new();
|
||||
|
||||
public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path);
|
||||
|
||||
public string LocalizedDescription =>
|
||||
string.IsNullOrEmpty(Description)
|
||||
? ""
|
||||
: string.Join("\n", Description.Split('\n').Select(SceneryI18n.T));
|
||||
|
||||
/// <summary>
|
||||
/// Renders the scenario the simulator should load: the original file with the
|
||||
/// user's consists, weather and optional includes applied.
|
||||
/// </summary>
|
||||
/// <param name="skipDecorTrainsets">
|
||||
/// Drops AI trainsets the user marked irrelevant, which shortens loading.
|
||||
/// </param>
|
||||
public string BuildExportContent(bool skipDecorTrainsets, IRandomSource random)
|
||||
{
|
||||
string result = Template;
|
||||
|
||||
if (Weather.Dirty)
|
||||
result = SceneryWeatherWriter.Rewrite(result, Weather, random);
|
||||
|
||||
result = InjectIncludes(result);
|
||||
|
||||
for (int i = 0; i < Trainsets.Count; i++)
|
||||
{
|
||||
string entry = skipDecorTrainsets && Trainsets[i].Decor
|
||||
? string.Empty
|
||||
: Trainsets[i].ToSceneryEntry();
|
||||
result = result.Replace("{{" + i + "}}", entry);
|
||||
}
|
||||
|
||||
if (LooseVehicles.Count > 0)
|
||||
result = AppendLooseVehicles(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string AppendLooseVehicles(string text)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var vehicle in LooseVehicles)
|
||||
sb.Append(vehicle.ToLooseNode());
|
||||
if (sb.Length == 0)
|
||||
return text;
|
||||
|
||||
return InsertBeforeFirstInit(text, sb.ToString(), fallbackAtEnd: true);
|
||||
}
|
||||
|
||||
private string InjectIncludes(string text)
|
||||
{
|
||||
if (Includes.Count == 0)
|
||||
return text;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var include in Includes)
|
||||
{
|
||||
// Kind 2 is the scenery's own timetable stand-in: it is only wanted
|
||||
// when the user has not supplied a companion .sbt.
|
||||
if (include.Kind == 2)
|
||||
{
|
||||
if (!HasCompanionTimetable)
|
||||
sb.Append("include ").Append(include.FilePath).Append(" end\r\n");
|
||||
continue;
|
||||
}
|
||||
if (include.Selected)
|
||||
sb.Append("include ").Append(include.FilePath).Append(" end\r\n");
|
||||
}
|
||||
|
||||
if (sb.Length == 0)
|
||||
return text;
|
||||
|
||||
return InsertBeforeFirstInit(text, sb.ToString(), fallbackAtEnd: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Places generated content ahead of <c>FirstInit</c>, which the simulator
|
||||
/// treats as the end of the definition section.
|
||||
/// </summary>
|
||||
private static string InsertBeforeFirstInit(string text, string addition, bool fallbackAtEnd)
|
||||
{
|
||||
var firstInit = Regex.Match(text, @"\bFirstInit\b", RegexOptions.IgnoreCase);
|
||||
if (firstInit.Success)
|
||||
return text[..firstInit.Index] + addition + text[firstInit.Index..];
|
||||
|
||||
return fallbackAtEnd ? text + addition : addition + text;
|
||||
}
|
||||
}
|
||||
105
StarterNG/Domain/Sceneries/SceneryWeather.cs
Normal file
105
StarterNG/Domain/Sceneries/SceneryWeather.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using StarterNG.Application.Abstractions;
|
||||
|
||||
namespace StarterNG.Domain.Sceneries;
|
||||
|
||||
/// <summary>
|
||||
/// The weather and time-of-day a scenery starts with: what the .scn file
|
||||
/// declared, what the user has changed it to, and whether that differs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Split out of <c>Scenery</c> so the "is this still the authored weather?"
|
||||
/// rule lives next to the values it compares, instead of being spread over the
|
||||
/// aggregate and the weather tab. The aggregate owns one of these; the parser
|
||||
/// fills it and calls <see cref="AcceptAsAuthored"/> once.
|
||||
/// </remarks>
|
||||
public sealed class SceneryWeather
|
||||
{
|
||||
public const int FogMin = 10;
|
||||
public const int FogMax = 10000;
|
||||
|
||||
/// <summary>Overcast values the "random" option picks between.</summary>
|
||||
private static readonly double[] OvercastPresets = { -1.5, 0, 0.3, 0.7, 1, 1.1, 1.5 };
|
||||
|
||||
private string _authoredTime = "10:30";
|
||||
private string _authoredTimeOverride = "10:30";
|
||||
private int _authoredDay;
|
||||
private double _authoredTemperature = 15;
|
||||
private int _authoredFogEnd = 2000;
|
||||
private double _authoredOvercast;
|
||||
private bool _authoredOvercastRandom;
|
||||
|
||||
public string Time { get; set; } = "10:30";
|
||||
|
||||
public string ScenarioTimeOverride { get; set; } = "10:30";
|
||||
|
||||
/// <summary>Day of the year driving the sun position (movelight).</summary>
|
||||
public int Day { get; set; }
|
||||
|
||||
public double Temperature { get; set; } = 15;
|
||||
|
||||
public int FogStart { get; set; } = 10;
|
||||
|
||||
public int FogEnd { get; set; } = 2000;
|
||||
|
||||
public double Overcast { get; set; }
|
||||
|
||||
/// <summary>When set, each export draws a fresh overcast from the presets.</summary>
|
||||
public bool OvercastRandom { get; set; }
|
||||
|
||||
/// <summary>True when the .scn actually declared weather, rather than us defaulting it.</summary>
|
||||
public bool IsAuthored { get; set; }
|
||||
|
||||
/// <summary>Set once the user has touched the weather, so export rewrites the config block.</summary>
|
||||
public bool Dirty { get; set; }
|
||||
|
||||
/// <summary>Takes the current values as the baseline "what the file said".</summary>
|
||||
public void AcceptAsAuthored()
|
||||
{
|
||||
_authoredTime = Time;
|
||||
_authoredTimeOverride = ScenarioTimeOverride;
|
||||
_authoredDay = Day;
|
||||
_authoredTemperature = Temperature;
|
||||
_authoredFogEnd = FogEnd;
|
||||
_authoredOvercast = Overcast;
|
||||
_authoredOvercastRandom = OvercastRandom;
|
||||
}
|
||||
|
||||
/// <summary>True when the user's weather differs from what the .scn declared.</summary>
|
||||
public bool Changed =>
|
||||
Day != _authoredDay ||
|
||||
ScenarioTimeOverride != _authoredTimeOverride ||
|
||||
Math.Abs(Temperature - _authoredTemperature) > 0.001 ||
|
||||
FogEnd != _authoredFogEnd ||
|
||||
OvercastRandom != _authoredOvercastRandom ||
|
||||
(!OvercastRandom && Math.Abs(Overcast - _authoredOvercast) > 0.001);
|
||||
|
||||
/// <summary>
|
||||
/// Puts back the authored weather. Stays <see cref="Dirty"/>: the export must
|
||||
/// still rewrite the config block, because the file on disk may already carry
|
||||
/// an edit from an earlier run.
|
||||
/// </summary>
|
||||
public void Restore()
|
||||
{
|
||||
Time = _authoredTime;
|
||||
ScenarioTimeOverride = _authoredTimeOverride;
|
||||
Day = _authoredDay;
|
||||
Temperature = _authoredTemperature;
|
||||
FogEnd = _authoredFogEnd;
|
||||
Overcast = _authoredOvercast;
|
||||
OvercastRandom = _authoredOvercastRandom;
|
||||
Dirty = true;
|
||||
}
|
||||
|
||||
/// <summary>Clamps a parsed fog range and picks the distance to run with.</summary>
|
||||
public void SetFogRange(int start, int end, IRandomSource random)
|
||||
{
|
||||
FogStart = Math.Clamp(start, FogMin, FogMax);
|
||||
end = Math.Clamp(end, FogStart, FogMax);
|
||||
FogEnd = end > FogStart ? random.Next(FogStart, end + 1) : end;
|
||||
}
|
||||
|
||||
/// <summary>The overcast to export, drawing a preset when randomisation is on.</summary>
|
||||
public double EffectiveOvercast(IRandomSource random) =>
|
||||
OvercastRandom ? OvercastPresets[random.Next(0, OvercastPresets.Length)] : Overcast;
|
||||
}
|
||||
93
StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs
Normal file
93
StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using StarterNG.Application.Abstractions;
|
||||
|
||||
namespace StarterNG.Domain.Sceneries;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a <see cref="SceneryWeather"/> back into the .scn dialect: strips the
|
||||
/// blocks the starter owns and prepends freshly written ones.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A pure text transformation, kept out of the aggregate because it is entirely
|
||||
/// about the file format rather than about the scenario.
|
||||
/// </remarks>
|
||||
public static class SceneryWeatherWriter
|
||||
{
|
||||
/// <summary>Config lines the starter writes itself and must not carry over.</summary>
|
||||
private static readonly Regex ManagedConfigLine = new(
|
||||
@"^\s*(movelight|scenario\.weather\.temperature|scenario\.time\.override)\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static string Rewrite(string text, SceneryWeather weather, IRandomSource random)
|
||||
{
|
||||
try
|
||||
{
|
||||
string carried = CarriedConfigLines(text);
|
||||
|
||||
string stripped = text;
|
||||
stripped = Regex.Replace(stripped, @"(?is)\bconfig\b.*?\bendconfig\b", " ");
|
||||
stripped = Regex.Replace(stripped, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " ");
|
||||
stripped = Regex.Replace(stripped, @"(?is)\batmo\b.*?\bendatmo\b", " ");
|
||||
stripped = Regex.Replace(stripped, @"(?im)^[ \t]*movelight\s+\S+", " ");
|
||||
stripped = Regex.Replace(stripped, @"(?i)scenario\.weather\.temperature\s+\S+", " ");
|
||||
stripped = Regex.Replace(stripped, @"(?i)scenario\.time\.override\s+\S+", " ");
|
||||
|
||||
var inv = CultureInfo.InvariantCulture;
|
||||
string config =
|
||||
"config\r\n" +
|
||||
$"movelight {weather.Day}\r\n" +
|
||||
$"scenario.weather.temperature {weather.Temperature.ToString(inv)}\r\n" +
|
||||
$"scenario.time.override {DelphiHourMinute(weather.ScenarioTimeOverride)}\r\n" +
|
||||
carried +
|
||||
"endconfig\r\n" +
|
||||
$"atmo 0 0 0 {weather.FogEnd} {weather.FogEnd} 0 0 0 " +
|
||||
$"{weather.EffectiveOvercast(random).ToString(inv)} endatmo\r\n" +
|
||||
$"time {DelphiHourMinute(weather.Time)} 0 0 endtime\r\n";
|
||||
|
||||
return config + stripped;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A scenery we cannot rewrite is still worth starting unmodified.
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The simulator's parser expects an unpadded hour ("7:30", not "07:30").
|
||||
/// </summary>
|
||||
private static string DelphiHourMinute(string hhmm)
|
||||
{
|
||||
int colon = hhmm.IndexOf(':');
|
||||
if (colon <= 0)
|
||||
return hhmm;
|
||||
|
||||
return hhmm[..colon].TrimStart('0') is { Length: > 0 } hour
|
||||
? hour + hhmm[colon..]
|
||||
: "0" + hhmm[colon..];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Config entries the scenery author wrote that are none of the starter's
|
||||
/// business, preserved verbatim across a rewrite.
|
||||
/// </summary>
|
||||
private static string CarriedConfigLines(string text)
|
||||
{
|
||||
var block = Regex.Match(text, @"(?is)\bconfig\b(.*?)\bendconfig\b");
|
||||
if (!block.Success)
|
||||
return "";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (string raw in block.Groups[1].Value.Split('\n'))
|
||||
{
|
||||
string line = raw.TrimEnd('\r').Trim();
|
||||
if (line.Length == 0 || ManagedConfigLine.IsMatch(line))
|
||||
continue;
|
||||
sb.Append(line).Append("\r\n");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Domain;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Domain;
|
||||
|
||||
|
||||
12
StarterNG/Infrastructure/Adapters/SystemRandomSource.cs
Normal file
12
StarterNG/Infrastructure/Adapters/SystemRandomSource.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using StarterNG.Application.Abstractions;
|
||||
|
||||
namespace StarterNG.Infrastructure.Adapters;
|
||||
|
||||
/// <summary><see cref="IRandomSource"/> backed by <see cref="Random.Shared"/>.</summary>
|
||||
public sealed class SystemRandomSource : IRandomSource
|
||||
{
|
||||
public int Next(int minInclusive, int maxExclusive) => Random.Shared.Next(minInclusive, maxExclusive);
|
||||
|
||||
public double NextDouble() => Random.Shared.NextDouble();
|
||||
}
|
||||
65
StarterNG/Infrastructure/Sceneries/SceneryImageLocator.cs
Normal file
65
StarterNG/Infrastructure/Sceneries/SceneryImageLocator.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Infrastructure.Sceneries;
|
||||
|
||||
/// <summary>
|
||||
/// Finds the preview image a scenery names, which authors place in any of
|
||||
/// several conventional spots relative to the .scn.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Was a lazy property on the aggregate; moved out because it is a lookup on
|
||||
/// disk. Results are cached per scenery path, which is what the lazy field
|
||||
/// bought us before.
|
||||
/// </remarks>
|
||||
public sealed class SceneryImageLocator
|
||||
{
|
||||
private readonly IFileSystem _files;
|
||||
private readonly Dictionary<string, string?> _cache = new();
|
||||
|
||||
public SceneryImageLocator(IFileSystem files)
|
||||
{
|
||||
_files = files;
|
||||
}
|
||||
|
||||
/// <summary>The image file for this scenery, or null when there is none.</summary>
|
||||
public string? Resolve(Scenery scenery)
|
||||
{
|
||||
lock (_cache)
|
||||
{
|
||||
if (_cache.TryGetValue(scenery.Path, out string? cached))
|
||||
return cached;
|
||||
|
||||
string? resolved = Search(scenery);
|
||||
_cache[scenery.Path] = resolved;
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
private string? Search(Scenery scenery)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scenery.ImageName))
|
||||
return null;
|
||||
|
||||
string name = scenery.ImageName.Replace('\\', '/').Trim();
|
||||
string sceneryDir = Path.GetDirectoryName(scenery.Path) ?? ".";
|
||||
string root = Path.GetDirectoryName(sceneryDir) ?? ".";
|
||||
|
||||
string[] candidates =
|
||||
{
|
||||
name,
|
||||
Path.Combine(root, name),
|
||||
Path.Combine(sceneryDir, name),
|
||||
Path.Combine(sceneryDir, "images", name),
|
||||
Path.Combine(root, "scenery", "images", name)
|
||||
};
|
||||
|
||||
foreach (string candidate in candidates)
|
||||
if (_files.FileExists(candidate))
|
||||
return candidate;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
297
StarterNG/Infrastructure/Sceneries/SceneryParser.cs
Normal file
297
StarterNG/Infrastructure/Sceneries/SceneryParser.cs
Normal file
@@ -0,0 +1,297 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Infrastructure.Sceneries;
|
||||
|
||||
/// <summary>
|
||||
/// Turns the text of a .scn file into a <see cref="Scenery"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All knowledge of the .scn dialect lives here, so the aggregate stays free of
|
||||
/// regular expressions and the parser can be exercised on a string. Anything
|
||||
/// malformed is recorded as a fault rather than thrown: a scenery with one bad
|
||||
/// consist is still worth listing.
|
||||
/// </remarks>
|
||||
public sealed class SceneryParser
|
||||
{
|
||||
private readonly IClock _clock;
|
||||
private readonly IRandomSource _random;
|
||||
|
||||
public SceneryParser(IClock clock, IRandomSource random)
|
||||
{
|
||||
_clock = clock;
|
||||
_random = random;
|
||||
}
|
||||
|
||||
public Scenery Parse(string path, string content)
|
||||
{
|
||||
// Directives, attachments and weather are read from the file as written;
|
||||
// the placeholder rewriting below only shapes the export template.
|
||||
string original = content;
|
||||
|
||||
var faults = new List<string>();
|
||||
var attachments = ParseAttachments(original);
|
||||
|
||||
var includes = new List<SceneryInclude>();
|
||||
content = StripOptionalIncludes(content, includes);
|
||||
|
||||
// Trainsets are lifted out first so the placeholders survive the loose
|
||||
// vehicle sweep below, which would otherwise match nodes inside them.
|
||||
var trainsetEntries = new List<string>();
|
||||
content = Regex.Replace(content, @"trainset\b[\s\S]*?\bendtrainset\b",
|
||||
match =>
|
||||
{
|
||||
trainsetEntries.Add(match.Value);
|
||||
return $"{{{{{trainsetEntries.Count - 1}}}}}";
|
||||
},
|
||||
RegexOptions.IgnoreCase);
|
||||
|
||||
var looseVehicles = new List<Dynamic>();
|
||||
var looseNames = new List<string>();
|
||||
content = ExtractLooseVehicles(content, looseVehicles, looseNames, faults);
|
||||
|
||||
var scenery = new Scenery(path, content)
|
||||
{
|
||||
Group = MatchDirective(original, "l"),
|
||||
Name = MatchDirective(original, "n"),
|
||||
Description = MatchAllDirectives(original, "d"),
|
||||
ImageName = MatchDirective(original, "i"),
|
||||
Archival = MatchDirective(original, "a") != null
|
||||
};
|
||||
|
||||
scenery.Attachments.AddRange(attachments);
|
||||
scenery.Includes.AddRange(includes);
|
||||
scenery.LooseVehicles.AddRange(looseVehicles);
|
||||
scenery.LooseVehicleNames.AddRange(looseNames);
|
||||
scenery.Faults.AddRange(faults);
|
||||
|
||||
ParseWeather(original, scenery.Weather);
|
||||
scenery.Weather.AcceptAsAuthored();
|
||||
|
||||
foreach (string entry in trainsetEntries)
|
||||
{
|
||||
var trainset = new Trainset(entry);
|
||||
scenery.Trainsets.Add(trainset);
|
||||
if (!trainset.Parsed)
|
||||
scenery.Faults.Add($"# trainset parse fault: {trainset.Name} ({trainset.Track})");
|
||||
}
|
||||
|
||||
return scenery;
|
||||
}
|
||||
|
||||
private static List<SceneryAttachment> ParseAttachments(string content)
|
||||
{
|
||||
var attachments = new List<SceneryAttachment>();
|
||||
|
||||
foreach (Match m in Regex.Matches(content, @"^//\$f\b[ \t]*([^\r\n]*)", RegexOptions.Multiline))
|
||||
{
|
||||
string rest = m.Groups[1].Value.Trim();
|
||||
if (rest.Length == 0)
|
||||
continue;
|
||||
|
||||
string[] parts = rest.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// A leading one- or two-character token is a legacy ordering hint.
|
||||
if (parts.Length >= 3 && parts[0].Length <= 2)
|
||||
attachments.Add(new SceneryAttachment
|
||||
{
|
||||
FilePath = parts[1],
|
||||
Label = string.Join(" ", parts.Skip(2))
|
||||
});
|
||||
else if (parts.Length >= 2)
|
||||
attachments.Add(new SceneryAttachment
|
||||
{
|
||||
FilePath = parts[0],
|
||||
Label = string.Join(" ", parts.Skip(1))
|
||||
});
|
||||
else
|
||||
attachments.Add(new SceneryAttachment
|
||||
{
|
||||
FilePath = parts[0],
|
||||
Label = System.IO.Path.GetFileName(parts[0])
|
||||
});
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the includes the scenery marked <c>$optional</c> from the text and
|
||||
/// records them, so the user can pick which ones the export puts back.
|
||||
/// </summary>
|
||||
private static string StripOptionalIncludes(string content, List<SceneryInclude> includes)
|
||||
{
|
||||
if (content.IndexOf("$optional", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
return content;
|
||||
|
||||
return Regex.Replace(
|
||||
content,
|
||||
@"include\s+(\S+(?:\s+\S+)*?)\s+end\s*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b",
|
||||
m =>
|
||||
{
|
||||
string includePath = Regex.Replace(m.Groups[1].Value.Trim(), @"\s+", " ");
|
||||
string raw = m.Groups[2].Value.Trim().TrimStart(',', ' ');
|
||||
string[] parameters = raw.Split(new[] { ',', '|', ';' },
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
int kind = 1;
|
||||
string description = includePath;
|
||||
if (parameters.Length >= 1 && int.TryParse(parameters[0], out int parsedKind))
|
||||
kind = parsedKind;
|
||||
if (parameters.Length >= 2)
|
||||
description = parameters[1];
|
||||
|
||||
includes.Add(new SceneryInclude
|
||||
{
|
||||
FilePath = includePath,
|
||||
Desc = description,
|
||||
Kind = kind,
|
||||
Selected = kind == 1
|
||||
});
|
||||
return " ";
|
||||
},
|
||||
RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lifts <c>node ... dynamic ... enddynamic</c> entries that sit outside any
|
||||
/// trainset, so the depot can show and reorder them like a consist.
|
||||
/// </summary>
|
||||
private static string ExtractLooseVehicles(string content, List<Dynamic> vehicles, List<string> names,
|
||||
List<string> faults)
|
||||
{
|
||||
var inv = CultureInfo.InvariantCulture;
|
||||
|
||||
return Regex.Replace(
|
||||
content,
|
||||
@"(?is)\bnode\s+(\S+)\s+(\S+)\s+(\S+)\s+dynamic\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)((?:\s+(?!enddynamic)\S+)*)\s*enddynamic\b",
|
||||
m =>
|
||||
{
|
||||
string name = m.Groups[3].Value;
|
||||
|
||||
// A placeholder means the node belongs to a trainset already lifted out.
|
||||
if (name.StartsWith("{{", StringComparison.Ordinal))
|
||||
return m.Value;
|
||||
|
||||
try
|
||||
{
|
||||
var vehicle = new Dynamic
|
||||
{
|
||||
RangeMax = float.Parse(m.Groups[1].Value, inv),
|
||||
RangeMin = float.Parse(m.Groups[2].Value, inv),
|
||||
Name = name,
|
||||
DataFolder = m.Groups[4].Value,
|
||||
SkinFile = Dynamic.StripSkinExtension(m.Groups[5].Value),
|
||||
MmdFile = m.Groups[6].Value,
|
||||
PathName = m.Groups[7].Value,
|
||||
Offset = float.Parse(m.Groups[8].Value, inv),
|
||||
DriverType = m.Groups[9].Value.ToLowerInvariant() switch
|
||||
{
|
||||
"headdriver" => eDriverType.Headdriver,
|
||||
"reardriver" => eDriverType.Reardriver,
|
||||
"passenger" => eDriverType.Passenger,
|
||||
_ => eDriverType.Nobody
|
||||
},
|
||||
HasVelocity = true,
|
||||
Velocity = float.Parse(m.Groups[10].Value, inv)
|
||||
};
|
||||
|
||||
var trailing = m.Groups[11].Value
|
||||
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
|
||||
.ToList();
|
||||
vehicle.ReadTrailing(trailing);
|
||||
|
||||
vehicles.Add(vehicle);
|
||||
names.Add(vehicle.Name);
|
||||
return "\n";
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Leave the node in the template so the export stays faithful.
|
||||
faults.Add($"# loose dynamic parse fault: {name}");
|
||||
names.Add(name);
|
||||
return m.Value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void ParseWeather(string content, SceneryWeather weather)
|
||||
{
|
||||
var time = Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b");
|
||||
if (time.Success)
|
||||
{
|
||||
weather.Time = $"{time.Groups[1].Value.PadLeft(2, '0')}:{time.Groups[2].Value}";
|
||||
weather.ScenarioTimeOverride = weather.Time;
|
||||
}
|
||||
|
||||
var over = Regex.Match(content, @"(?i)scenario\.time\.override\s+(\d{1,2})[:.](\d{2})");
|
||||
if (over.Success)
|
||||
{
|
||||
weather.ScenarioTimeOverride = $"{over.Groups[1].Value.PadLeft(2, '0')}:{over.Groups[2].Value}";
|
||||
weather.IsAuthored = true;
|
||||
}
|
||||
else if (!time.Success)
|
||||
{
|
||||
// Nothing authored: start the scenario at the player's wall clock.
|
||||
var now = _clock.Now;
|
||||
weather.ScenarioTimeOverride = $"{now.Hour:D2}:{now.Minute:D2}";
|
||||
}
|
||||
|
||||
var move = Regex.Match(content, @"(?im)\bmovelight\s+(-?\d+)");
|
||||
if (move.Success && int.TryParse(move.Groups[1].Value, out int day))
|
||||
{
|
||||
weather.Day = day;
|
||||
weather.IsAuthored = true;
|
||||
}
|
||||
|
||||
var temperature = Regex.Match(content, @"(?i)scenario\.weather\.temperature\s+(-?\d+(?:\.\d+)?)");
|
||||
if (temperature.Success &&
|
||||
double.TryParse(temperature.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture,
|
||||
out double celsius))
|
||||
{
|
||||
weather.Temperature = celsius;
|
||||
weather.IsAuthored = true;
|
||||
}
|
||||
|
||||
var atmo = Regex.Match(content, @"(?is)\batmo\b(.*?)\bendatmo\b");
|
||||
if (!atmo.Success)
|
||||
return;
|
||||
|
||||
var numbers = Regex.Matches(atmo.Groups[1].Value, @"-?\d+(?:\.\d+)?")
|
||||
.Select(m => m.Value)
|
||||
.ToList();
|
||||
|
||||
if (numbers.Count >= 5 &&
|
||||
int.TryParse(numbers[3], out int fogStart) &&
|
||||
int.TryParse(numbers[4], out int fogEnd))
|
||||
{
|
||||
weather.SetFogRange(fogStart, fogEnd, _random);
|
||||
weather.IsAuthored = true;
|
||||
}
|
||||
|
||||
if (numbers.Count >= 6 &&
|
||||
double.TryParse(numbers[^1], NumberStyles.Float, CultureInfo.InvariantCulture, out double overcast))
|
||||
weather.Overcast = overcast;
|
||||
}
|
||||
|
||||
private static string? MatchAllDirectives(string content, string letter)
|
||||
{
|
||||
var matches = Regex.Matches(content, @"^//\$" + letter + @"\b[ \t]*([^\r\n]*)", RegexOptions.Multiline);
|
||||
return matches.Count == 0
|
||||
? null
|
||||
: string.Join("\n", matches.Select(m => m.Groups[1].Value.TrimEnd()));
|
||||
}
|
||||
|
||||
private static string? MatchDirective(string content, string letter)
|
||||
{
|
||||
var match = Regex.Match(content, @"^//\$" + letter + @"\b[ \t]*([^\r\n]*)", RegexOptions.Multiline);
|
||||
return match.Success ? match.Groups[1].Value.Trim() : null;
|
||||
}
|
||||
}
|
||||
101
StarterNG/Infrastructure/Sceneries/SceneryRepository.cs
Normal file
101
StarterNG/Infrastructure/Sceneries/SceneryRepository.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Infrastructure.Sceneries;
|
||||
|
||||
/// <summary>
|
||||
/// Loads scenarios from the installation's scenery folder.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sceneries are code page 1250 text, and a large installation holds hundreds of
|
||||
/// them, so the sweep is parallel; each file that throws is logged and dropped
|
||||
/// rather than taking the whole load down.
|
||||
/// </remarks>
|
||||
public sealed class SceneryRepository : ISceneryRepository
|
||||
{
|
||||
/// <summary>Files starting with '$' are authoring scratch, not scenarios.</summary>
|
||||
private const char ExcludedPrefix = '$';
|
||||
|
||||
private readonly IFileSystem _files;
|
||||
private readonly IGamePaths _paths;
|
||||
private readonly IDiagnosticsLog _log;
|
||||
private readonly SceneryParser _parser;
|
||||
private readonly Encoding _encoding;
|
||||
|
||||
public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser)
|
||||
{
|
||||
_files = files;
|
||||
_paths = paths;
|
||||
_log = log;
|
||||
_parser = parser;
|
||||
_encoding = CodePage1250();
|
||||
}
|
||||
|
||||
public IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null)
|
||||
{
|
||||
var files = SceneryFiles().ToList();
|
||||
var loaded = new ConcurrentBag<Scenery>();
|
||||
int total = Math.Max(1, files.Count);
|
||||
int done = 0;
|
||||
|
||||
Parallel.ForEach(files,
|
||||
new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
|
||||
file =>
|
||||
{
|
||||
var scenery = Load(file);
|
||||
if (scenery is not null)
|
||||
loaded.Add(scenery);
|
||||
|
||||
progress?.Report(new SceneryLoadProgress(Interlocked.Increment(ref done), total,
|
||||
Path.GetFileName(file)));
|
||||
});
|
||||
|
||||
return loaded.ToList();
|
||||
}
|
||||
|
||||
public Scenery? Load(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_files.FileExists(path))
|
||||
return null;
|
||||
|
||||
var scenery = _parser.Parse(path, _files.ReadAllText(path, _encoding));
|
||||
scenery.HasCompanionTimetable = _files.FileExists(Path.ChangeExtension(path, ".sbt"));
|
||||
return scenery;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log($"scenery/{Path.GetFileName(path)}", ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<string> SceneryFiles() =>
|
||||
_files.GetFiles(_paths.Scenery, "*.scn")
|
||||
.Where(path => Path.GetFileName(path).FirstOrDefault() != ExcludedPrefix);
|
||||
|
||||
/// <summary>
|
||||
/// The .scn dialect predates Unicode. The provider is registered by the entry
|
||||
/// point; fall back to Latin-1 if that ever fails so loading still works.
|
||||
/// </summary>
|
||||
private static Encoding CodePage1250()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Encoding.GetEncoding(1250);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return Encoding.Latin1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ using StarterNG.Infrastructure;
|
||||
using StarterNG.Services;
|
||||
using StarterNG.Views;
|
||||
using SettingsModel = StarterNG.Classes.Settings;
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG;
|
||||
|
||||
@@ -443,14 +445,14 @@ public partial class MainWindow : Window
|
||||
|
||||
LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path));
|
||||
|
||||
scenery.WeatherDirty = true;
|
||||
scenery.Weather.Dirty = true;
|
||||
|
||||
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(SettingsModel.Instance.IgnoreIrrelevantTrains), Encoding.GetEncoding(1250));
|
||||
File.WriteAllText(exportPath, scenery.BuildExportContent(SettingsModel.Instance.IgnoreIrrelevantTrains, AppServices.Current.Random), Encoding.GetEncoding(1250));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ using Avalonia.Threading;
|
||||
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media.Imaging;
|
||||
using StarterNG.Classes;
|
||||
using StarterNG.Domain;
|
||||
using StarterNG.Application;
|
||||
using StarterNG.Domain.Sceneries;
|
||||
|
||||
namespace StarterNG.Views;
|
||||
|
||||
@@ -310,7 +312,7 @@ public partial class Scenarios : UserControl
|
||||
SceneryI18n.LoadFor(scenery, App.Loc.CurrentLangCode);
|
||||
sceneryDescription.Text = scenery.LocalizedDescription;
|
||||
|
||||
string? imagePath = scenery.ImagePath;
|
||||
string? imagePath = AppServices.Current.SceneryImages.Resolve(scenery);
|
||||
if (imagePath is not null)
|
||||
{
|
||||
try
|
||||
@@ -551,16 +553,17 @@ public partial class Scenarios : UserControl
|
||||
try
|
||||
{
|
||||
weatherForm.IsEnabled = true;
|
||||
weatherTime.SelectedTime = ParseTime(scenery.ScenarioTimeOverride);
|
||||
weatherDate.SelectedDate = DateOfDayOfYear(scenery.Day);
|
||||
weatherTemp.Value = scenery.Temperature;
|
||||
weatherFog.Value = SliderFromFog(scenery.FogEnd);
|
||||
SelectByTag(weatherSeason, scenery.Day.ToString(), excludeFromNearest: "0");
|
||||
SelectByTag(weatherOvercast, scenery.OvercastRandom
|
||||
var weather = scenery.Weather;
|
||||
weatherTime.SelectedTime = ParseTime(weather.ScenarioTimeOverride);
|
||||
weatherDate.SelectedDate = DateOfDayOfYear(weather.Day);
|
||||
weatherTemp.Value = weather.Temperature;
|
||||
weatherFog.Value = SliderFromFog(weather.FogEnd);
|
||||
SelectByTag(weatherSeason, weather.Day.ToString(), excludeFromNearest: "0");
|
||||
SelectByTag(weatherOvercast, weather.OvercastRandom
|
||||
? RandomTag
|
||||
: scenery.Overcast.ToString(CultureInfo.InvariantCulture));
|
||||
: weather.Overcast.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
ApplyTodayPreview(scenery.Day == 0);
|
||||
ApplyTodayPreview(weather.Day == 0);
|
||||
UpdateWeatherLabels();
|
||||
}
|
||||
finally
|
||||
@@ -574,22 +577,22 @@ public partial class Scenarios : UserControl
|
||||
if (_loadingWeather || _currentScenery is null)
|
||||
return;
|
||||
|
||||
var scn = _currentScenery;
|
||||
var weather = _currentScenery.Weather;
|
||||
if (weatherTime.SelectedTime is { } t)
|
||||
scn.ScenarioTimeOverride = $"{t.Hours:D2}:{t.Minutes:D2}";
|
||||
weather.ScenarioTimeOverride = $"{t.Hours:D2}:{t.Minutes:D2}";
|
||||
|
||||
scn.Day = _todaySeason ? 0 : SelectedDayOfYear();
|
||||
scn.Temperature = weatherTemp.Value;
|
||||
scn.FogEnd = FogFromSlider(weatherFog.Value);
|
||||
weather.Day = _todaySeason ? 0 : SelectedDayOfYear();
|
||||
weather.Temperature = weatherTemp.Value;
|
||||
weather.FogEnd = FogFromSlider(weatherFog.Value);
|
||||
if ((weatherOvercast.SelectedItem as ComboBoxItem)?.Tag is string ov)
|
||||
{
|
||||
scn.OvercastRandom = ov == RandomTag;
|
||||
if (!scn.OvercastRandom &&
|
||||
weather.OvercastRandom = ov == RandomTag;
|
||||
if (!weather.OvercastRandom &&
|
||||
double.TryParse(ov, NumberStyles.Float, CultureInfo.InvariantCulture, out double oc))
|
||||
scn.Overcast = oc;
|
||||
weather.Overcast = oc;
|
||||
}
|
||||
|
||||
scn.WeatherDirty = true;
|
||||
weather.Dirty = true;
|
||||
UpdateWeatherLabels();
|
||||
}
|
||||
|
||||
@@ -599,23 +602,23 @@ public partial class Scenarios : UserControl
|
||||
weatherFogLabel.Text = FormatFog(FogFromSlider(weatherFog.Value));
|
||||
weatherDayDate.Text = string.Format(LanguageCulture, App.Loc["DayOfYear"], SelectedDayOfYear());
|
||||
|
||||
weatherRestore.IsEnabled = _currentScenery?.WeatherChanged ?? false;
|
||||
weatherRestore.IsEnabled = _currentScenery?.Weather.Changed ?? false;
|
||||
}
|
||||
|
||||
private static int FogFromSlider(double position)
|
||||
{
|
||||
double metres = Scenery.FogMin *
|
||||
System.Math.Pow((double)Scenery.FogMax / Scenery.FogMin, position / 1000.0);
|
||||
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),
|
||||
Scenery.FogMin, Scenery.FogMax);
|
||||
SceneryWeather.FogMin, SceneryWeather.FogMax);
|
||||
}
|
||||
|
||||
private static double SliderFromFog(int metres)
|
||||
{
|
||||
double clamped = System.Math.Clamp(metres, Scenery.FogMin, Scenery.FogMax);
|
||||
return 1000.0 * System.Math.Log(clamped / Scenery.FogMin) /
|
||||
System.Math.Log((double)Scenery.FogMax / Scenery.FogMin);
|
||||
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) =>
|
||||
@@ -688,7 +691,7 @@ public partial class Scenarios : UserControl
|
||||
private void WeatherRestore_OnClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_currentScenery is null) return;
|
||||
_currentScenery.RestoreWeather();
|
||||
_currentScenery.Weather.Restore();
|
||||
ShowWeather(_currentScenery);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user