mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-10-17 07:59:19 +02:00
Move new domain/infra modules out of Classes into Domain/ and Infrastructure/
Consist/scenery/vehicle logic (TrainsetDisplay, TexRandomizer, SceneryI18n) goes under Domain/; the loading-screen bitmap writer under Infrastructure/, instead of the flat Classes/ bucket.
This commit is contained in:
92
StarterNG/Domain/SceneryI18n.cs
Normal file
92
StarterNG/Domain/SceneryI18n.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using StarterNG.Classes;
|
||||
|
||||
namespace StarterNG.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Pascal <c>TI18n</c> / <c>TrToken</c>: scenery strings starting with <c>@</c>
|
||||
/// resolve against <c>scenery/i18n/{id}_{lang}.json</c>.
|
||||
/// </summary>
|
||||
public static class SceneryI18n
|
||||
{
|
||||
private static readonly Dictionary<string, string> Map =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static void Clear() => Map.Clear();
|
||||
|
||||
/// <param name="langCode">Short UI language code (pl/en/cz/hu/ru).</param>
|
||||
public static void LoadFor(Scenery scenery, string langCode = "en")
|
||||
{
|
||||
Map.Clear();
|
||||
string scnDir = Path.GetDirectoryName(Path.GetFullPath(scenery.Path)) ?? "scenery";
|
||||
string id = Path.GetFileNameWithoutExtension(scenery.Path);
|
||||
string lang = NormalizeLang(langCode);
|
||||
string i18nDir = Path.Combine(scnDir, "i18n");
|
||||
|
||||
// Fallback first (en), then active language overlays — same as Pascal.
|
||||
TryLoad(Path.Combine(i18nDir, $"{id}_en.json"));
|
||||
if (!string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase))
|
||||
TryLoad(Path.Combine(i18nDir, $"{id}_{lang}.json"));
|
||||
}
|
||||
|
||||
/// <summary>Translate <c>@token</c> / <c>@nested.key</c>; plain text passes through.</summary>
|
||||
public static string T(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return text ?? "";
|
||||
string t = text.TrimStart();
|
||||
if (t.Length == 0 || t[0] != '@') return text;
|
||||
string key = t[1..].Trim();
|
||||
return Map.TryGetValue(key, out var v) ? v : text;
|
||||
}
|
||||
|
||||
private static void TryLoad(string path)
|
||||
{
|
||||
if (!File.Exists(path)) return;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
Flatten("", doc.RootElement);
|
||||
}
|
||||
catch { /* ignore bad i18n files */ }
|
||||
}
|
||||
|
||||
private static void Flatten(string prefix, JsonElement obj)
|
||||
{
|
||||
foreach (var prop in obj.EnumerateObject())
|
||||
{
|
||||
string key = string.IsNullOrEmpty(prefix) ? prop.Name : prefix + "." + prop.Name;
|
||||
switch (prop.Value.ValueKind)
|
||||
{
|
||||
case JsonValueKind.String:
|
||||
Map[key] = prop.Value.GetString() ?? "";
|
||||
break;
|
||||
case JsonValueKind.Number:
|
||||
case JsonValueKind.True:
|
||||
case JsonValueKind.False:
|
||||
Map[key] = prop.Value.ToString();
|
||||
break;
|
||||
case JsonValueKind.Object:
|
||||
Flatten(key, prop.Value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeLang(string language)
|
||||
{
|
||||
string l = language.Trim().ToLowerInvariant();
|
||||
return l switch
|
||||
{
|
||||
"polski" or "pl" => "pl",
|
||||
"english" or "en" => "en",
|
||||
"čeština" or "cesky" or "czech" or "cs" or "cz" => "cz",
|
||||
"magyar" or "hungarian" or "hu" => "hu",
|
||||
"русский" or "russian" or "ru" => "ru",
|
||||
_ => l.Length >= 2 ? l[..2] : "en"
|
||||
};
|
||||
}
|
||||
}
|
||||
241
StarterNG/Domain/TexRandomizer.cs
Normal file
241
StarterNG/Domain/TexRandomizer.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using StarterNG.Classes;
|
||||
|
||||
namespace StarterNG.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Skin shuffle for the depot consist — same idea as Pascal <c>TTexRandomizer</c>:
|
||||
/// pick another texture of a similar mini/class (expanded by <c>starter/reguly.txt</c>),
|
||||
/// optionally clamped by operator and revision year.
|
||||
/// </summary>
|
||||
public sealed class TexRandomizer
|
||||
{
|
||||
private readonly VehicleDatabase _db;
|
||||
private readonly Random _rng;
|
||||
private readonly List<string[]> _stock = new();
|
||||
|
||||
public int RevisionTolerance { get; set; } = -1;
|
||||
public int RevYear { get; set; } = -1;
|
||||
public bool WithoutArchival { get; set; } = true;
|
||||
|
||||
public static string RulesPath
|
||||
{
|
||||
get
|
||||
{
|
||||
string cwd = Directory.GetCurrentDirectory();
|
||||
string primary = Path.Combine(cwd, "starter", "reguly.txt");
|
||||
if (File.Exists(primary)) return primary;
|
||||
string bundled = Path.Combine(AppContext.BaseDirectory, "startercfg", "reguly.txt");
|
||||
if (File.Exists(bundled)) return bundled;
|
||||
return primary;
|
||||
}
|
||||
}
|
||||
|
||||
public TexRandomizer(VehicleDatabase db, Random? rng = null)
|
||||
{
|
||||
_db = db;
|
||||
_rng = rng ?? Random.Shared;
|
||||
LoadRules();
|
||||
}
|
||||
|
||||
public void LoadRules()
|
||||
{
|
||||
_stock.Clear();
|
||||
string path = RulesPath;
|
||||
if (!File.Exists(path)) return;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (string line in File.ReadAllLines(path))
|
||||
{
|
||||
string trimmed = line.Trim();
|
||||
if (trimmed.Length == 0 || trimmed[0] == '#') continue;
|
||||
string[] parts = trimmed.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length > 0)
|
||||
_stock.Add(parts);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// bad rules file — keep going with an empty stock
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reassigns skins on <paramref name="consist"/> in place. Returns how many units changed.
|
||||
/// </summary>
|
||||
public int Apply(IList<ConsistItem> consist, Func<VehicleTexture, Dynamic> makeCar)
|
||||
{
|
||||
int changed = 0;
|
||||
for (int i = 0; i < consist.Count; i++)
|
||||
{
|
||||
var item = consist[i];
|
||||
if (item.Cars.Count == 0) continue;
|
||||
|
||||
var current = _db.TextureForSkin(item.Cars[0].SkinFile);
|
||||
if (current != null && _db.IsSetFollower(current))
|
||||
continue;
|
||||
|
||||
string? mini = ResolveFilterMini(item, i, consist, current);
|
||||
if (string.IsNullOrEmpty(mini)) continue;
|
||||
|
||||
// Pascal skips units that have a revision filter on but no parseable year.
|
||||
int? revYear = null;
|
||||
if (RevisionTolerance > 0)
|
||||
{
|
||||
revYear = RevYear > 0 ? RevYear : ParseRevisionYear(current?.Meta?.RevisionDate);
|
||||
if (revYear is null) continue;
|
||||
}
|
||||
|
||||
string? owner = null;
|
||||
string? cat = current?.ResolvedCategory;
|
||||
// Keep operator match for non-EMU / non-railbus (Pascal skips EZT/SZYNOBUS).
|
||||
if (current != null && cat is not ("z" or "a"))
|
||||
owner = current.Meta?.Operator;
|
||||
|
||||
var aliases = ExpandMini(mini);
|
||||
var pool = FindCandidates(aliases, owner, revYear, current);
|
||||
if (pool.Count < 2) continue;
|
||||
|
||||
var pick = pool[_rng.Next(pool.Count)];
|
||||
if (ReferenceEquals(pick, current) ||
|
||||
string.Equals(pick.Skinfile, current?.Skinfile, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var set = _db.ResolveSet(pick);
|
||||
var unit = set ?? new List<VehicleTexture> { pick };
|
||||
consist[i] = new ConsistItem
|
||||
{
|
||||
Cars = unit.Select(makeCar).ToList(),
|
||||
Grouped = unit.Count > 1,
|
||||
Driver = item.Driver,
|
||||
Flipped = item.Flipped
|
||||
};
|
||||
changed++;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private string? ResolveFilterMini(ConsistItem item, int index, IList<ConsistItem> consist, VehicleTexture? current)
|
||||
{
|
||||
if (current != null)
|
||||
{
|
||||
string skin = current.Skinfile ?? "";
|
||||
if (skin.Contains("112E", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
if (current.Wreck) return null;
|
||||
|
||||
string mini = current.ResolvedClass;
|
||||
if (string.IsNullOrEmpty(mini))
|
||||
mini = _db.ResolveMiniName(current) ?? "";
|
||||
return string.IsNullOrEmpty(mini) ? null : mini;
|
||||
}
|
||||
|
||||
// No texture in DB — borrow from a neighbour like Pascal does for loose dynamics.
|
||||
if (index > 0)
|
||||
{
|
||||
var prev = _db.TextureForSkin(consist[index - 1].Cars[0].SkinFile);
|
||||
if (prev != null && !string.IsNullOrEmpty(prev.ResolvedClass))
|
||||
return prev.ResolvedClass;
|
||||
}
|
||||
if (index + 1 < consist.Count)
|
||||
{
|
||||
var next = _db.TextureForSkin(consist[index + 1].Cars[0].SkinFile);
|
||||
if (next != null && !string.IsNullOrEmpty(next.ResolvedClass))
|
||||
return next.ResolvedClass;
|
||||
}
|
||||
|
||||
bool driven = item.Driver is eDriverType.Headdriver or eDriverType.Reardriver;
|
||||
return driven ? "st44" : "A_112A";
|
||||
}
|
||||
|
||||
private List<string> ExpandMini(string mini)
|
||||
{
|
||||
var list = new List<string> { mini };
|
||||
string upper = mini.ToUpperInvariant();
|
||||
foreach (var stock in _stock)
|
||||
{
|
||||
bool hit = stock.Any(s => s.ToUpperInvariant().Contains(upper) || upper.Contains(s.ToUpperInvariant()));
|
||||
if (!hit) continue;
|
||||
foreach (string s in stock)
|
||||
{
|
||||
if (!list.Contains(s, StringComparer.OrdinalIgnoreCase))
|
||||
list.Add(s);
|
||||
}
|
||||
break;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<VehicleTexture> FindCandidates(
|
||||
IReadOnlyList<string> minis,
|
||||
string? owner,
|
||||
int? revYear,
|
||||
VehicleTexture? current)
|
||||
{
|
||||
var result = new List<VehicleTexture>();
|
||||
foreach (var t in _db.Textures)
|
||||
{
|
||||
if (t.Wreck) continue;
|
||||
if (WithoutArchival && t.ResolvedArchived) continue;
|
||||
if (_db.IsSetFollower(t)) continue;
|
||||
|
||||
string candidateMini = t.ResolvedClass;
|
||||
if (string.IsNullOrEmpty(candidateMini))
|
||||
candidateMini = _db.ResolveMiniName(t) ?? "";
|
||||
if (string.IsNullOrEmpty(candidateMini)) continue;
|
||||
|
||||
bool miniOk = minis.Any(m =>
|
||||
string.Equals(m, candidateMini, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(m, t.TextureMini, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(m, t.MiniRef, StringComparison.OrdinalIgnoreCase));
|
||||
if (!miniOk) continue;
|
||||
|
||||
if (!string.IsNullOrEmpty(owner) &&
|
||||
!string.Equals(owner, t.Meta?.Operator, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
if (revYear is int year && RevisionTolerance > 0)
|
||||
{
|
||||
int? y = ParseRevisionYear(t.Meta?.RevisionDate);
|
||||
if (y is null || Math.Abs(y.Value - year) > RevisionTolerance)
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(t);
|
||||
}
|
||||
|
||||
// Always keep the current skin as a possible "no change" so Count < 2 means
|
||||
// there is nothing else to pick from.
|
||||
if (current != null && !result.Contains(current))
|
||||
result.Add(current);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Pulls a 4-digit year from dates like <c>12.05.2018</c> or <c>2018-05-12</c>.</summary>
|
||||
public static int? ParseRevisionYear(string? revision)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(revision)) return null;
|
||||
|
||||
// Pascal: Copy(Revision, 7, 4) on dd.mm.yyyy
|
||||
if (revision.Length >= 10 && revision[2] == '.' && revision[5] == '.' &&
|
||||
int.TryParse(revision.AsSpan(6, 4), out int yDot) && yDot is >= 1900 and <= 2100)
|
||||
return yDot;
|
||||
|
||||
for (int i = 0; i + 3 < revision.Length; i++)
|
||||
{
|
||||
if (char.IsDigit(revision[i]) && char.IsDigit(revision[i + 1]) &&
|
||||
char.IsDigit(revision[i + 2]) && char.IsDigit(revision[i + 3]))
|
||||
{
|
||||
int y = (revision[i] - '0') * 1000 + (revision[i + 1] - '0') * 100
|
||||
+ (revision[i + 2] - '0') * 10 + (revision[i + 3] - '0');
|
||||
if (y is >= 1900 and <= 2100) return y;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
272
StarterNG/Domain/TrainsetDisplay.cs
Normal file
272
StarterNG/Domain/TrainsetDisplay.cs
Normal file
@@ -0,0 +1,272 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StarterNG.Classes;
|
||||
|
||||
namespace StarterNG.Domain;
|
||||
|
||||
/// <summary>Shared trainset list labels and status-bar formatting for Scenarios / Depot.</summary>
|
||||
public static class TrainsetDisplay
|
||||
{
|
||||
public static bool IsEmpty(Trainset trainset) =>
|
||||
trainset.Vehicles.Count == 0 ||
|
||||
trainset.Vehicles.All(v => string.IsNullOrWhiteSpace(v.Name) && string.IsNullOrWhiteSpace(v.SkinFile));
|
||||
|
||||
/// <summary>
|
||||
/// Composition caption like Pascal <c>PrepareTrainsetDesc</c>: first loco/EZT by
|
||||
/// name, then collapse consecutive same-class wagons as <c>Mini(N)</c>.
|
||||
/// </summary>
|
||||
public static string? CompositionText(Trainset trainset)
|
||||
{
|
||||
if (IsEmpty(trainset))
|
||||
return null;
|
||||
|
||||
var db = GameData.Instance.Vehicles;
|
||||
var sb = new StringBuilder();
|
||||
string? prevKey = null;
|
||||
int run = 0;
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (prevKey is null || run == 0) return;
|
||||
if (sb.Length > 0) sb.Append(" + ");
|
||||
sb.Append(run > 1 ? $"{prevKey}({run})" : prevKey);
|
||||
prevKey = null;
|
||||
run = 0;
|
||||
}
|
||||
|
||||
for (int i = 0; i < trainset.Vehicles.Count; i++)
|
||||
{
|
||||
var v = trainset.Vehicles[i];
|
||||
var tex = db.TextureForSkin(v.SkinFile);
|
||||
string cat = tex?.ResolvedCategory ?? "";
|
||||
bool powered = cat is "e" or "s" or "p" or "z" or "a";
|
||||
|
||||
if (i == 0 && powered)
|
||||
{
|
||||
string head = !string.IsNullOrWhiteSpace(v.Name) ? v.Name! :
|
||||
(!string.IsNullOrEmpty(tex?.ResolvedClass) ? tex!.ResolvedClass : BaseSkin(v));
|
||||
sb.Append(head);
|
||||
prevKey = null;
|
||||
run = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
string key = !string.IsNullOrEmpty(tex?.ResolvedClass) ? tex!.ResolvedClass
|
||||
: (!string.IsNullOrWhiteSpace(v.Name) ? v.Name! : BaseSkin(v));
|
||||
|
||||
if (string.Equals(key, prevKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
run++;
|
||||
}
|
||||
else
|
||||
{
|
||||
Flush();
|
||||
prevKey = key;
|
||||
run = 1;
|
||||
}
|
||||
}
|
||||
Flush();
|
||||
return sb.Length > 0 ? sb.ToString() : string.Join(" + ", trainset.Vehicles.Select(v =>
|
||||
!string.IsNullOrWhiteSpace(v.Name) ? v.Name! : BaseSkin(v)));
|
||||
}
|
||||
|
||||
private static string BaseSkin(Dynamic v) =>
|
||||
string.IsNullOrWhiteSpace(v.SkinFile) ? "?" :
|
||||
System.IO.Path.GetFileNameWithoutExtension(v.SkinFile);
|
||||
|
||||
/// <summary>
|
||||
/// Like Pascal <c>SelectTrain</c>: prefer the last car if it has cab crew
|
||||
/// (push-pull), else the first staffed/passenger car, else the first car.
|
||||
/// </summary>
|
||||
public static string? DefaultStartingVehicle(Trainset trainset)
|
||||
{
|
||||
if (trainset.Vehicles.Count == 0)
|
||||
return null;
|
||||
|
||||
var last = trainset.Vehicles[^1];
|
||||
if (last.DriverType is eDriverType.Headdriver or eDriverType.Reardriver)
|
||||
return last.Name;
|
||||
|
||||
var staffed = trainset.Vehicles.FirstOrDefault(v =>
|
||||
v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger);
|
||||
return staffed?.Name ?? trainset.Vehicles[0].Name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Length / mass / vehicle count / track (+ speed), with Pascal mass rules
|
||||
/// (<c>IncludeVehicleToMass</c> + 200 t AllVehicles retry).
|
||||
/// </summary>
|
||||
public static string FormatStats(Trainset? trainset, Func<Dynamic, Physics?> physicsFor,
|
||||
string lengthLabel, string massLabel, string vehiclesLabel, string trackLabel,
|
||||
Func<Dynamic, string?>? categoryOf = null,
|
||||
Func<string?, int>? loadWeightKg = null)
|
||||
{
|
||||
if (trainset is null)
|
||||
return "";
|
||||
|
||||
var (lengthM, massKg, loadKg) = RecalcParams(trainset, physicsFor, categoryOf, loadWeightKg, allVehicles: false);
|
||||
if (massKg < 200_000)
|
||||
(lengthM, massKg, loadKg) = RecalcParams(trainset, physicsFor, categoryOf, loadWeightKg, allVehicles: true);
|
||||
|
||||
var inv = CultureInfo.InvariantCulture;
|
||||
string track = trainset.Track ?? "";
|
||||
if (trainset.Velocity > 0)
|
||||
track = $"{track} ({trainset.Velocity.ToString("0.#", inv)} km/h)";
|
||||
|
||||
string massPart = $"{massLabel}: {(massKg / 1000.0).ToString("0.#", inv)} t";
|
||||
if (loadKg > 0)
|
||||
massPart += $" / {((massKg + loadKg) / 1000.0).ToString("0.#", inv)} t";
|
||||
|
||||
return $"{lengthLabel}: {lengthM.ToString("0.#", inv)} m · " +
|
||||
$"{massPart} · " +
|
||||
$"{vehiclesLabel}: {trainset.Vehicles.Count} · " +
|
||||
$"{trackLabel}: {track}";
|
||||
}
|
||||
|
||||
private static (double lengthM, double massKg, double loadKg) RecalcParams(
|
||||
Trainset trainset,
|
||||
Func<Dynamic, Physics?> physicsFor,
|
||||
Func<Dynamic, string?>? categoryOf,
|
||||
Func<string?, int>? loadWeightKg,
|
||||
bool allVehicles)
|
||||
{
|
||||
double lengthM = 0, massKg = 0, loadKg = 0;
|
||||
foreach (var car in trainset.Vehicles)
|
||||
{
|
||||
var p = physicsFor(car);
|
||||
if (p != null)
|
||||
lengthM += p.Length;
|
||||
|
||||
if (!IncludeVehicleToMass(car, p, categoryOf?.Invoke(car), allVehicles))
|
||||
continue;
|
||||
|
||||
if (p != null)
|
||||
massKg += p.Mass;
|
||||
|
||||
if (loadWeightKg != null &&
|
||||
car.LoadCount > 0 &&
|
||||
!string.IsNullOrEmpty(car.LoadType) &&
|
||||
LoadAccepted(p, car.LoadType))
|
||||
loadKg += (double)car.LoadCount * loadWeightKg(car.LoadType);
|
||||
}
|
||||
return (lengthM, massKg, loadKg);
|
||||
}
|
||||
|
||||
private static bool IncludeVehicleToMass(Dynamic car, Physics? p, string? category, bool allVehicles)
|
||||
{
|
||||
if (p == null) return false;
|
||||
if (allVehicles) return true;
|
||||
if (category is "z" or "a") return true;
|
||||
return car.DriverType is eDriverType.Passenger or eDriverType.Nobody;
|
||||
}
|
||||
|
||||
private static bool LoadAccepted(Physics? p, string loadType)
|
||||
{
|
||||
if (p == null || string.IsNullOrWhiteSpace(p.LoadAccepted))
|
||||
return true;
|
||||
return p.LoadAccepted.Contains(loadType, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pascal <c>UniqueVehicleName</c> / <c>UniqueVehiclesName</c> at launch:
|
||||
/// EZT/szynobus → rename every car; otherwise only the start vehicle.
|
||||
/// Returns the (possibly new) name of the start vehicle.
|
||||
/// </summary>
|
||||
public static string? UniquifyForLaunch(Trainset trainset, Scenery scenery, string? startName)
|
||||
{
|
||||
var used = CollectNames(scenery);
|
||||
var db = GameData.Instance.Vehicles;
|
||||
|
||||
bool mu = trainset.Vehicles.Any(v =>
|
||||
{
|
||||
string? cat = db.TextureForSkin(v.SkinFile)?.ResolvedCategory;
|
||||
return cat is "z" or "a";
|
||||
});
|
||||
|
||||
Dynamic? target = null;
|
||||
if (!string.IsNullOrEmpty(startName))
|
||||
target = trainset.Vehicles.FirstOrDefault(v =>
|
||||
string.Equals(v.Name, startName, StringComparison.OrdinalIgnoreCase));
|
||||
target ??= trainset.Vehicles.FirstOrDefault(v =>
|
||||
v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger);
|
||||
target ??= trainset.Vehicles.FirstOrDefault();
|
||||
|
||||
if (mu)
|
||||
{
|
||||
foreach (var v in trainset.Vehicles)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(v.Name))
|
||||
used.Remove(v.Name!);
|
||||
v.Name = EnsureUnique(PreferredBaseName(v), used);
|
||||
}
|
||||
return target?.Name;
|
||||
}
|
||||
|
||||
if (target != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(target.Name))
|
||||
used.Remove(target.Name!);
|
||||
target.Name = EnsureUnique(PreferredBaseName(target), used);
|
||||
}
|
||||
return target?.Name;
|
||||
}
|
||||
|
||||
public static string PreferredBaseName(Dynamic v)
|
||||
{
|
||||
var tex = GameData.Instance.Vehicles.TextureForSkin(v.SkinFile);
|
||||
if (tex != null)
|
||||
{
|
||||
// Prefer texture_mini when it is more specific than the class mini (≈ MiniD).
|
||||
if (!string.IsNullOrEmpty(tex.TextureMini) &&
|
||||
!string.Equals(tex.TextureMini, tex.ResolvedClass, StringComparison.OrdinalIgnoreCase))
|
||||
return SanitizeName(tex.TextureMini!);
|
||||
if (!string.IsNullOrEmpty(tex.Skinfile))
|
||||
return SanitizeName(Path.GetFileNameWithoutExtension(tex.Skinfile));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(v.SkinFile))
|
||||
return SanitizeName(Path.GetFileNameWithoutExtension(v.SkinFile));
|
||||
return SanitizeName(string.IsNullOrWhiteSpace(v.Name) ? "vehicle" : v.Name!);
|
||||
}
|
||||
|
||||
public static string EnsureUnique(string baseName, HashSet<string> used)
|
||||
{
|
||||
baseName = SanitizeName(baseName);
|
||||
if (!used.Contains(baseName))
|
||||
{
|
||||
used.Add(baseName);
|
||||
return baseName;
|
||||
}
|
||||
int n = 1;
|
||||
string candidate;
|
||||
do
|
||||
candidate = $"{baseName}_{n++}";
|
||||
while (used.Contains(candidate));
|
||||
used.Add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
public static HashSet<string> CollectNames(Scenery scenery, Trainset? except = null)
|
||||
{
|
||||
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string n in scenery.LooseVehicleNames)
|
||||
used.Add(n);
|
||||
foreach (var d in scenery.LooseVehicles)
|
||||
if (!string.IsNullOrEmpty(d.Name))
|
||||
used.Add(d.Name!);
|
||||
foreach (var ts in scenery.Trainsets)
|
||||
{
|
||||
if (ReferenceEquals(ts, except)) continue;
|
||||
foreach (var v in ts.Vehicles)
|
||||
if (!string.IsNullOrEmpty(v.Name))
|
||||
used.Add(v.Name!);
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
private static string SanitizeName(string name) =>
|
||||
string.IsNullOrWhiteSpace(name) ? "vehicle" : name.Replace(' ', '_');
|
||||
}
|
||||
127
StarterNG/Infrastructure/LoadingScreen.cs
Normal file
127
StarterNG/Infrastructure/LoadingScreen.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
|
||||
namespace StarterNG.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Pascal <c>PrepareLoadingScreen</c>: pick a logo and write <c>textures/logo.bmp</c>
|
||||
/// for the simulator splash.
|
||||
/// </summary>
|
||||
public static class LoadingScreen
|
||||
{
|
||||
public static void Prepare(string? logoKey, string? sceneryName = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
string? src = ResolveSource(logoKey) ?? ResolveSource(sceneryName) ?? PickRandom();
|
||||
if (src is null) return;
|
||||
|
||||
string destDir = Path.Combine(Directory.GetCurrentDirectory(), "textures");
|
||||
Directory.CreateDirectory(destDir);
|
||||
string dest = Path.Combine(destDir, "logo.bmp");
|
||||
|
||||
if (src.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Copy(src, dest, overwrite: true);
|
||||
return;
|
||||
}
|
||||
|
||||
WriteBmp(src, dest);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// never block launch on a bad logo
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveSource(string? key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return null;
|
||||
key = Path.GetFileNameWithoutExtension(key.Trim());
|
||||
string dir = Path.Combine(Directory.GetCurrentDirectory(), "textures", "logo");
|
||||
foreach (string ext in new[] { ".bmp", ".jpg", ".jpeg", ".png" })
|
||||
{
|
||||
string path = Path.Combine(dir, key + ext);
|
||||
if (File.Exists(path)) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? PickRandom()
|
||||
{
|
||||
string dir = Path.Combine(Directory.GetCurrentDirectory(), "textures", "logo");
|
||||
if (!Directory.Exists(dir)) return null;
|
||||
var files = Directory.GetFiles(dir, "logo*.*")
|
||||
.Where(f =>
|
||||
{
|
||||
string e = Path.GetExtension(f).ToLowerInvariant();
|
||||
return e is ".bmp" or ".jpg" or ".jpeg" or ".png";
|
||||
})
|
||||
.ToArray();
|
||||
if (files.Length == 0) return null;
|
||||
return files[Random.Shared.Next(files.Length)];
|
||||
}
|
||||
|
||||
private static void WriteBmp(string sourcePath, string destPath)
|
||||
{
|
||||
using var src = new Bitmap(sourcePath);
|
||||
int w = src.PixelSize.Width;
|
||||
int h = src.PixelSize.Height;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
|
||||
var wb = new WriteableBitmap(src.PixelSize, src.Dpi, PixelFormat.Bgra8888, AlphaFormat.Opaque);
|
||||
using var fb = wb.Lock();
|
||||
src.CopyPixels(new PixelRect(0, 0, w, h), fb.Address, fb.RowBytes * h, fb.RowBytes);
|
||||
|
||||
int srcStride = fb.RowBytes;
|
||||
var pixels = new byte[srcStride * h];
|
||||
Marshal.Copy(fb.Address, pixels, 0, pixels.Length);
|
||||
|
||||
int rowStride = ((w * 3 + 3) / 4) * 4;
|
||||
int pixelBytes = rowStride * h;
|
||||
var file = new byte[54 + pixelBytes];
|
||||
|
||||
file[0] = (byte)'B'; file[1] = (byte)'M';
|
||||
WriteInt32(file, 2, file.Length);
|
||||
WriteInt32(file, 10, 54);
|
||||
WriteInt32(file, 14, 40);
|
||||
WriteInt32(file, 18, w);
|
||||
WriteInt32(file, 22, h);
|
||||
WriteInt16(file, 26, 1);
|
||||
WriteInt16(file, 28, 24);
|
||||
WriteInt32(file, 34, pixelBytes);
|
||||
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
int destY = h - 1 - y;
|
||||
int destOff = 54 + destY * rowStride;
|
||||
int srcOff = y * srcStride;
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int si = srcOff + x * 4;
|
||||
int di = destOff + x * 3;
|
||||
file[di] = pixels[si];
|
||||
file[di + 1] = pixels[si + 1];
|
||||
file[di + 2] = pixels[si + 2];
|
||||
}
|
||||
}
|
||||
|
||||
File.WriteAllBytes(destPath, file);
|
||||
}
|
||||
|
||||
private static void WriteInt32(byte[] b, int o, int v)
|
||||
{
|
||||
b[o] = (byte)v; b[o + 1] = (byte)(v >> 8);
|
||||
b[o + 2] = (byte)(v >> 16); b[o + 3] = (byte)(v >> 24);
|
||||
}
|
||||
|
||||
private static void WriteInt16(byte[] b, int o, int v)
|
||||
{
|
||||
b[o] = (byte)v; b[o + 1] = (byte)(v >> 8);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user