mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-10-17 07:59:19 +02:00
Draw a stand-in for a missing thumbnail, pass the language code to eu07.ini
The file was getting "pl" or "en" whatever the user picked, so a simulator with its own language file started in English.
This commit is contained in:
76
StarterNG.Tests/MiniTextureIndexTests.cs
Normal file
76
StarterNG.Tests/MiniTextureIndexTests.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using StarterNG.Infrastructure.Adapters;
|
||||
using StarterNG.Infrastructure.Vehicles;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class MiniTextureIndexTests
|
||||
{
|
||||
private static readonly string[] Names =
|
||||
{
|
||||
"ep07", "et22", "111a", "112a", "203v", "401k", "en57", "sm42", "st44", "412w",
|
||||
"406r", "424z", "621z", "z1kd", "gags", "eaos", "falns", "uacs", "sgs", "kbs"
|
||||
};
|
||||
|
||||
private static MiniTextureIndex Build(int missRate)
|
||||
{
|
||||
var files = new InMemoryFileSystem();
|
||||
foreach (string name in Names)
|
||||
files.WithFile(TestInstallation.At("textures", "mini", name + ".bmp"), "bitmap");
|
||||
files.WithFile(TestInstallation.At("textures", "mini", "other.bmp"), "bitmap");
|
||||
|
||||
var environment = new FakeEnvironment();
|
||||
if (missRate > 0)
|
||||
environment.With(MiniTextureIndex.MissRateVariable, missRate.ToString());
|
||||
|
||||
return new MiniTextureIndex(files, new GamePaths(TestInstallation.Root), environment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_the_debug_variable_every_thumbnail_loads()
|
||||
{
|
||||
var index = Build(missRate: 0);
|
||||
|
||||
Assert.Equal(0, index.SimulatedMissRate);
|
||||
Assert.All(Names, name => Assert.True(index.Has(name)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_debug_variable_hides_roughly_the_share_it_names()
|
||||
{
|
||||
var index = Build(missRate: 50);
|
||||
|
||||
int hidden = Names.Count(name => !index.Has(name));
|
||||
|
||||
// Twenty names is a small sample, so this only asserts that the switch
|
||||
// bites and does not take everything with it.
|
||||
Assert.InRange(hidden, 3, Names.Length - 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_hidden_thumbnail_stays_hidden_rather_than_flickering()
|
||||
{
|
||||
var index = Build(missRate: 50);
|
||||
string hidden = Names.First(name => !index.Has(name));
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
Assert.False(index.Has(hidden));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_hidden_thumbnail_falls_back_to_other_bmp_like_an_unknown_one()
|
||||
{
|
||||
var index = Build(missRate: 50);
|
||||
string hidden = Names.First(name => !index.Has(name));
|
||||
|
||||
Assert.EndsWith("other.bmp", index.PathFor(hidden));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_name_still_falls_back_to_other_bmp_in_a_normal_run()
|
||||
{
|
||||
var index = Build(missRate: 0);
|
||||
|
||||
Assert.EndsWith("other.bmp", index.PathFor("nie-ma-takiej"));
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,50 @@ public class SettingsStoreTests
|
||||
Assert.Equal(1920, settings.Width);
|
||||
Assert.Equal(1080, settings.Height);
|
||||
Assert.True(settings.Fullscreen);
|
||||
Assert.Equal("Polski", settings.Language);
|
||||
Assert.Equal("pl", settings.LanguageCode);
|
||||
Assert.True(settings.LanguageWasSet);
|
||||
Assert.Equal(UserIni, installation.SettingsStore.LoadedFrom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_language_the_starter_does_not_ship_survives_a_round_trip()
|
||||
{
|
||||
// A user-supplied translation must reach the simulator as written; folding
|
||||
// every unknown code into "en" is what forced eu07.exe into English.
|
||||
var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "lang zh\n"));
|
||||
installation.SettingsStore.Load();
|
||||
|
||||
Assert.Equal("zh", installation.SettingsStore.Settings.LanguageCode);
|
||||
|
||||
installation.SettingsStore.Save();
|
||||
|
||||
Assert.Contains("lang zh", installation.Files.ReadAllText(UserIni));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_language_chosen_in_the_picker_is_written_as_its_own_code()
|
||||
{
|
||||
var installation = WithHome();
|
||||
installation.SettingsStore.Load();
|
||||
installation.SettingsStore.Settings.Language = "中文";
|
||||
installation.SettingsStore.Settings.LanguageCode = "zh";
|
||||
|
||||
installation.SettingsStore.Save();
|
||||
|
||||
Assert.Contains("lang zh", installation.Files.ReadAllText(UserIni));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_no_language_recorded_the_file_still_names_one()
|
||||
{
|
||||
var installation = WithHome();
|
||||
installation.SettingsStore.Load();
|
||||
|
||||
installation.SettingsStore.Save();
|
||||
|
||||
Assert.Contains("lang en", installation.Files.ReadAllText(UserIni));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_first_run_falls_back_to_the_ini_shipped_with_the_installation()
|
||||
{
|
||||
|
||||
@@ -51,7 +51,12 @@ public partial class App : Avalonia.Application
|
||||
{
|
||||
|
||||
var resolvedName = Loc.Load(langNameOrCode);
|
||||
|
||||
// The service is the authority on which language actually loaded: the code
|
||||
// goes to eu07.ini for the simulator, the name to the picker.
|
||||
AppServices.Current.Settings.Language = resolvedName;
|
||||
AppServices.Current.Settings.LanguageCode = Loc.CurrentLangCode;
|
||||
|
||||
ApplyCulture(Loc.CurrentLangCode);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,13 +43,19 @@ public sealed class AppServices
|
||||
Localization = new LocalizationService(log);
|
||||
Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random));
|
||||
SceneryImages = new SceneryImageLocator(files);
|
||||
MiniTextures = new MiniTextureIndex(files, paths);
|
||||
MiniTextures = new MiniTextureIndex(files, paths, environment);
|
||||
Vehicles = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
|
||||
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);
|
||||
// Leaves a trail when the thumbnail debug switch is on, so a run with
|
||||
// missing minis is never mistaken for a broken installation.
|
||||
if (MiniTextures is MiniTextureIndex { SimulatedMissRate: > 0 } debugMinis)
|
||||
log.Log($"{MiniTextureIndex.MissRateVariable}={debugMinis.SimulatedMissRate}: " +
|
||||
"ukrywam ten procent miniatur pojazdow");
|
||||
|
||||
Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
|
||||
State = new AppState();
|
||||
|
||||
|
||||
@@ -27,7 +27,19 @@ public sealed class SimulatorSettings
|
||||
/// </summary>
|
||||
public bool LanguageWasSet { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name of the starter's language, as the picker shows it ("Polski",
|
||||
/// "English", "中文"). Resolved from <see cref="LanguageCode"/> at startup.
|
||||
/// </summary>
|
||||
public string Language = "English";
|
||||
|
||||
/// <summary>
|
||||
/// The language's code, as its startercfg/lang/*.xml declares it and as
|
||||
/// eu07.ini stores it. This is what the simulator reads, so it has to carry
|
||||
/// whatever language the user actually picked rather than being folded into
|
||||
/// one of two known ones.
|
||||
/// </summary>
|
||||
public string LanguageCode = "";
|
||||
public bool Fullscreen;
|
||||
public bool PauseWhenInactive = true;
|
||||
public bool PauseOnStart;
|
||||
|
||||
@@ -23,7 +23,13 @@ public sealed class SettingsSerializer
|
||||
{
|
||||
|
||||
s.LanguageWasSet = c.Has("lang");
|
||||
s.Language = c.GetString("lang", "en").ToLowerInvariant() == "pl" ? "Polski" : "English";
|
||||
// Kept as the code the file holds. Startup hands it to the localisation
|
||||
// service, which resolves it to a display name and writes that back; the
|
||||
// starter must not decide here that an unrecognised code means English,
|
||||
// because the same key is what tells the simulator which language to run
|
||||
// in.
|
||||
s.LanguageCode = c.GetString("lang", "");
|
||||
s.Language = s.LanguageCode;
|
||||
s.Fullscreen = c.GetBool("fullscreen", false);
|
||||
s.PauseWhenInactive = c.GetBool("inactivepause", true);
|
||||
s.PauseOnStart = c.GetBool("pause", false);
|
||||
@@ -165,7 +171,7 @@ public sealed class SettingsSerializer
|
||||
foreach (string key in ObsoleteKeys)
|
||||
c.Remove(key);
|
||||
|
||||
c.Set("lang", s.Language == "Polski" ? "pl" : "en");
|
||||
c.Set("lang", string.IsNullOrWhiteSpace(s.LanguageCode) ? "en" : s.LanguageCode);
|
||||
c.SetBool("fullscreen", s.Fullscreen);
|
||||
c.SetBool("inactivepause", s.PauseWhenInactive);
|
||||
c.SetBool("pause", s.PauseOnStart);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using StarterNG.Application.Abstractions;
|
||||
|
||||
@@ -18,26 +19,45 @@ public sealed class MiniTextureIndex : IMiniTextureIndex
|
||||
/// <summary>Thumbnail shown when a vehicle names one we do not have.</summary>
|
||||
private const string FallbackName = "other";
|
||||
|
||||
/// <summary>
|
||||
/// Set to 1-100 to make that percentage of thumbnails behave as if the
|
||||
/// installation did not have their file - the vehicle then falls back to
|
||||
/// other.bmp exactly as one naming an unknown thumbnail would. For checking
|
||||
/// how a consist looks when some of its stock has no picture of its own.
|
||||
/// Off unless the variable is set.
|
||||
/// </summary>
|
||||
public const string MissRateVariable = "STARTER_DEBUG_MISSING_MINI";
|
||||
|
||||
private readonly IFileSystem _files;
|
||||
private readonly IGamePaths _paths;
|
||||
private readonly int _missRate;
|
||||
private readonly object _gate = new();
|
||||
|
||||
private Dictionary<string, string>? _index;
|
||||
|
||||
public MiniTextureIndex(IFileSystem files, IGamePaths paths)
|
||||
public MiniTextureIndex(IFileSystem files, IGamePaths paths, IEnvironment? environment = null)
|
||||
{
|
||||
_files = files;
|
||||
_paths = paths;
|
||||
_missRate = ReadMissRate(environment);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Percentage of thumbnails currently being hidden on purpose; 0 in a normal
|
||||
/// run.
|
||||
/// </summary>
|
||||
public int SimulatedMissRate => _missRate;
|
||||
|
||||
public void Preload() => Index();
|
||||
|
||||
public bool Has(string? miniName) =>
|
||||
!string.IsNullOrEmpty(miniName) && Index().ContainsKey(miniName.ToLowerInvariant());
|
||||
!string.IsNullOrEmpty(miniName) &&
|
||||
!IsSimulatedMiss(miniName) &&
|
||||
Index().ContainsKey(miniName.ToLowerInvariant());
|
||||
|
||||
public string? PathFor(string? miniName)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(miniName) &&
|
||||
if (!IsSimulatedMiss(miniName) && !string.IsNullOrEmpty(miniName) &&
|
||||
Index().TryGetValue(miniName.ToLowerInvariant(), out string? path))
|
||||
return path;
|
||||
|
||||
@@ -47,6 +67,35 @@ public sealed class MiniTextureIndex : IMiniTextureIndex
|
||||
public string? FallbackPath =>
|
||||
Index().TryGetValue(FallbackName, out string? path) ? path : null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this name is one of the thumbnails the debug switch is hiding.
|
||||
/// Decided from the name itself rather than a random draw, so a vehicle keeps
|
||||
/// the same answer every time the strip is rebuilt and can actually be looked
|
||||
/// at.
|
||||
/// </summary>
|
||||
private bool IsSimulatedMiss(string? miniName) =>
|
||||
_missRate > 0 && !string.IsNullOrEmpty(miniName) && Bucket(miniName) < _missRate;
|
||||
|
||||
/// <summary>FNV-1a over the lower-cased name, folded into 0-99.</summary>
|
||||
private static int Bucket(string name)
|
||||
{
|
||||
uint hash = 2166136261;
|
||||
foreach (char c in name.ToLowerInvariant())
|
||||
{
|
||||
hash ^= c;
|
||||
hash *= 16777619;
|
||||
}
|
||||
return (int)(hash % 100);
|
||||
}
|
||||
|
||||
private static int ReadMissRate(IEnvironment? environment)
|
||||
{
|
||||
string? raw = environment?.GetVariable(MissRateVariable);
|
||||
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int rate)
|
||||
? Math.Clamp(rate, 0, 100)
|
||||
: 0;
|
||||
}
|
||||
|
||||
private Dictionary<string, string> Index()
|
||||
{
|
||||
lock (_gate)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Shapes;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
@@ -25,6 +29,75 @@ public sealed class MiniTextures
|
||||
return image;
|
||||
}
|
||||
|
||||
/// <summary>Body of the drawn stand-in; the black wagon of the old starter.</summary>
|
||||
private static readonly IBrush MissingBody = new SolidColorBrush(Color.Parse("#0B0C0D"));
|
||||
|
||||
private static readonly IBrush MissingEdge = new SolidColorBrush(Color.Parse("#3A424A"));
|
||||
|
||||
private static readonly IBrush MissingMark = new SolidColorBrush(Color.Parse("#E6E8EA"));
|
||||
|
||||
/// <summary>Real minis are about 80x30, and the stand-in keeps that shape.</summary>
|
||||
private const double MiniAspect = 80.0 / 30.0;
|
||||
|
||||
/// <summary>
|
||||
/// The stand-in for a vehicle with no thumbnail: a dark wagon marked with
|
||||
/// question marks, the way the old starter drew textures/mini/other.bmp.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reached only when the installation has no other.bmp either - a vehicle
|
||||
/// naming a thumbnail we do not have already falls back to that file. Drawn
|
||||
/// rather than loaded so the consist keeps its length whatever is missing;
|
||||
/// a vehicle that is simply absent from the strip reads as a shorter train.
|
||||
/// </remarks>
|
||||
public static Control MissingVisual(string? label, int height)
|
||||
{
|
||||
double width = height * MiniAspect;
|
||||
double bodyTop = height * 0.23;
|
||||
double bodyHeight = height * 0.47;
|
||||
double wheelRadius = height * 0.13;
|
||||
|
||||
var canvas = new Canvas { Width = width, Height = height };
|
||||
|
||||
var body = new Border
|
||||
{
|
||||
Width = width - 4,
|
||||
Height = bodyHeight,
|
||||
Background = MissingBody,
|
||||
BorderBrush = MissingEdge,
|
||||
BorderThickness = new Thickness(1),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = "? ?",
|
||||
FontSize = Math.Max(9, height * 0.34),
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = MissingMark,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
}
|
||||
};
|
||||
Canvas.SetLeft(body, 2);
|
||||
Canvas.SetTop(body, bodyTop);
|
||||
canvas.Children.Add(body);
|
||||
|
||||
foreach (double centre in new[] { width * 0.22, width * 0.78 })
|
||||
{
|
||||
var wheel = new Ellipse
|
||||
{
|
||||
Width = wheelRadius * 2,
|
||||
Height = wheelRadius * 2,
|
||||
Fill = MissingBody
|
||||
};
|
||||
Canvas.SetLeft(wheel, centre - wheelRadius);
|
||||
Canvas.SetTop(wheel, bodyTop + bodyHeight - wheelRadius);
|
||||
canvas.Children.Add(wheel);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(label))
|
||||
ToolTip.SetTip(canvas, label);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
public Bitmap? Get(string? name, int height, bool strict = false)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
|
||||
@@ -92,18 +92,9 @@ public sealed class VehicleCards
|
||||
}
|
||||
else
|
||||
{
|
||||
minis.Children.Add(MemberFrame(new Border
|
||||
{
|
||||
Height = VehicleCardStyle.ThumbHeight, Width = 90, Background = VehicleCardStyle.Placeholder,
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = car.SkinFile, FontSize = 10, TextWrapping = TextWrapping.Wrap,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextAlignment = TextAlignment.Center
|
||||
}
|
||||
}, item, car, CardCaption(item, car, index)));
|
||||
minis.Children.Add(MemberFrame(
|
||||
MiniTextures.MissingVisual(car.SkinFile, VehicleCardStyle.ThumbHeight),
|
||||
item, car, CardCaption(item, car, index)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -427,26 +427,6 @@ public partial class Scenarios : UserControl
|
||||
private static readonly Avalonia.Media.IBrush SelectedVehicleBrush =
|
||||
new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#41C400"));
|
||||
|
||||
private static readonly Avalonia.Media.IBrush MissingMiniBrush =
|
||||
new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#22808080"));
|
||||
|
||||
private static Control MissingMiniBox(string? label, int height) => new Border
|
||||
{
|
||||
Height = height,
|
||||
Width = height * 2,
|
||||
Background = MissingMiniBrush,
|
||||
CornerRadius = new Avalonia.CornerRadius(4),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontSize = 10,
|
||||
TextWrapping = Avalonia.Media.TextWrapping.Wrap,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextAlignment = Avalonia.Media.TextAlignment.Center
|
||||
}
|
||||
};
|
||||
|
||||
private readonly Dictionary<string, Bitmap?> _miniCache = new();
|
||||
|
||||
private Bitmap? LoadMini(string? name, int height)
|
||||
@@ -834,7 +814,7 @@ public partial class Scenarios : UserControl
|
||||
Stretch = Avalonia.Media.Stretch.Uniform,
|
||||
Margin = new Thickness(0)
|
||||
})
|
||||
: MissingMiniBox(train.SkinFile, thumbH);
|
||||
: MiniTextures.MissingVisual(train.SkinFile, thumbH);
|
||||
|
||||
bool selected = !string.IsNullOrEmpty(train.Name) &&
|
||||
string.Equals(train.Name, AppServices.Current.State.StartingVehicleName,
|
||||
@@ -1025,14 +1005,11 @@ public partial class Scenarios : UserControl
|
||||
for (int i = _dragBlockStart; i < _dragBlockStart + _dragBlockLength; i++)
|
||||
{
|
||||
var source = consistStack.Children[i];
|
||||
// The ghost mirrors what the slot shows, stand-in wagons included, so
|
||||
// dragging a vehicle with no thumbnail still carries something visible.
|
||||
Control copy = source is Border { Child: Image image }
|
||||
? new Image { Source = image.Source, Height = image.Height, Stretch = image.Stretch }
|
||||
: new Border
|
||||
{
|
||||
Width = source.Bounds.Width,
|
||||
Height = source.Bounds.Height,
|
||||
Background = MissingMiniBrush
|
||||
};
|
||||
: MiniTextures.MissingVisual(null, (int)source.Bounds.Height);
|
||||
panel.Children.Add(copy);
|
||||
}
|
||||
|
||||
|
||||
@@ -277,8 +277,9 @@ public partial class Settings : UserControl, ISettingsCapture
|
||||
public void CaptureInto(SimulatorSettings s)
|
||||
{
|
||||
|
||||
s.Language = (ChangeLanguageCb.SelectedItem as ComboBoxItem)?.Content?.ToString()
|
||||
?? App.Loc.CurrentLanguage;
|
||||
var language = ChangeLanguageCb.SelectedItem as ComboBoxItem;
|
||||
s.Language = language?.Content?.ToString() ?? App.Loc.CurrentLanguage;
|
||||
s.LanguageCode = language?.Tag as string ?? App.Loc.CurrentLangCode;
|
||||
s.Fullscreen = IsChecked(FullscreenCb);
|
||||
s.PauseWhenInactive = IsChecked(PauseInactiveCb);
|
||||
s.PauseOnStart = IsChecked(PauseStartCb);
|
||||
|
||||
Reference in New Issue
Block a user