Compare commits
12 commits
aec4d2ecc4
...
478e0a93cc
| Author | SHA1 | Date | |
|---|---|---|---|
| 478e0a93cc | |||
| 5e18cb84a1 | |||
| 48c9b97029 | |||
| cc552a0246 | |||
| 5187c03ebe | |||
| 16d118c4a9 | |||
| 9f2097e579 | |||
| 290211e9e8 | |||
| e8aeb2d6a3 | |||
| 7acf6b6841 | |||
| 1d4d5a6312 | |||
| b25496bbe4 |
100 changed files with 21533 additions and 775 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -11,3 +11,6 @@ native/out/
|
|||
# IDE
|
||||
.vs/
|
||||
*.user
|
||||
|
||||
# Local game logs accidentally copied into the repo
|
||||
railloader*.log
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"Id": "S3",
|
||||
"DisplayName": "S³ - Seton's Special Sauce",
|
||||
"Author": "seton",
|
||||
"Version": "0.2.7",
|
||||
"Version": "0.3b",
|
||||
"ManagerVersion": "0.27.0",
|
||||
"GameVersionPoint": "0",
|
||||
"AssemblyName": "S3.dll",
|
||||
|
|
|
|||
77
README.md
77
README.md
|
|
@ -16,10 +16,16 @@ I originally planned on releasing individual mods, but considering my workflow o
|
|||
|
||||
| Module | What it does |
|
||||
|---|---|
|
||||
| Map Module | In-game map overlay and detachable popout window for a second monitor. Themes, custom colors, opacity controls, map rotation, track & industry labels, and optional MapEnhancer integration. |
|
||||
| Map Module | In-game map overlay and detachable popout window for a second monitor. Themes, custom colors, opacity, rotation, track labels, view presets, waypoint pins, and optional MapEnhancer integration. |
|
||||
| Physics Optimizer | Cuts CPU spent on train physics (LOD fast-path + auto-freeze), with debug car tinting. Console: `/rpf` |
|
||||
| Mesh LOD | Adds level-of-detail to rolling stock: distant cars progressively shed detail and finally collapse to a cheap proxy box, cutting triangle count on large saves. |
|
||||
| Profiler | Unified in-game performance overlay: a frame-time graph plus live readouts that adapt to whichever optimization modules are enabled. Console: `/rpf overlay` |
|
||||
| Base Game Performance | Smooths Unity's incremental garbage collector and Nature Renderer grass streaming to reduce camera-motion hitches without lowering visual quality. |
|
||||
| Profiler | Unified in-game performance overlay with hitch attribution captures. Console: `/rpf overlay`, `/s3bench` |
|
||||
| Misc Tweaks | Small QoL: cancellable autoload of the most recent save from the main menu. |
|
||||
| Quick Actions | Extra outer-ring couple/air/cut actions and a consist hover wheel on the rolling-stock pie menu. |
|
||||
| Car Cards | Fanned consist dock for the selected cut, with waybill, notes, and couple/handbrake/locate actions. |
|
||||
| Industry Tags | In-world business, track, and yard callouts with live industry data. |
|
||||
| MCP | Localhost agent server so a coding tool can inspect the live game. Disabled by default. |
|
||||
|
||||
All modules are disabled by default; enable them per-module from the S³ settings page. A game restart is required for enable/disable to take effect. More modules will follow; S³ is designed to grow.
|
||||
|
||||
|
|
@ -90,6 +96,10 @@ The map labels named tracks and industries, read live from the game's industry d
|
|||
|
||||

|
||||
|
||||
### View Presets and Waypoints
|
||||
|
||||
Save named camera bookmarks from the map and jump back to them later. Optional waypoint pins show Auto Engineer and WaypointQueue stops on the map camera, with matching loco icon tints. `/s3wq dump` writes a consist and queue dump when you need to inspect a session.
|
||||
|
||||
### Popout Window
|
||||
|
||||
Pop the map into a detached native OS window. Drag it to any monitor, resize it freely, and pin it always-on-top via the window's right-click title bar menu. Re-attach it back into the game overlay at any time from the settings panel without losing your position, zoom, or rotation.
|
||||
|
|
@ -159,14 +169,32 @@ All three LOD reductions side by side, with the transition distances set artific
|
|||
|
||||
---
|
||||
|
||||
## Base Game Performance
|
||||
|
||||
Direct per-frame profiling found that the repeatable base-game stalls were garbage-collection
|
||||
frames rather than car culling, scenery loading, world streaming, or camera ground queries.
|
||||
This module reduces Unity's incremental GC slice from the game's 3 ms default to a configurable
|
||||
1 ms starting point. The work is spread across more frames, reducing individual stalls without
|
||||
changing resolution, shadows, draw distance, scenery, rolling-stock detail, or simulation.
|
||||
|
||||
It can also cap Nature Renderer grass-cell streaming (instance budget, queued nearby loads,
|
||||
and staggered cell expiry) so abrupt camera looks hitch less. Density and draw distance stay
|
||||
the same. The original runtime values are restored immediately when the module is disabled.
|
||||
|
||||
---
|
||||
|
||||
## Profiler
|
||||
|
||||
A unified in-game performance overlay. It always shows a render + physics frame-time graph (render, FixedUpdate, Tick, and PosCars times) and a timing report, and it grows extra sections for whichever optimization modules are enabled:
|
||||
A unified in-game performance overlay. It shows a render + physics frame-time graph and now
|
||||
supports automated hitch-attribution captures with per-frame GC, camera, render-pipeline,
|
||||
car/scenery/culling, and streaming timings. Benchmark output includes `frames.csv`,
|
||||
`hitches.jsonl`, `probes.csv`, and an optional Unity binary-profiler log.
|
||||
|
||||
- **Physics Optimizer**: LOD fast-path and auto-freeze quick-toggles with live fast/full and frozen counts.
|
||||
- **Mesh LOD**: total tracked cars, the locomotive/freight split, and how many cars sit at each LOD level right now.
|
||||
|
||||
Toggle the overlay with `/rpf overlay`, or from the Profiler settings page.
|
||||
Toggle the overlay with `/rpf overlay`, or from the Profiler settings page. Run
|
||||
`/s3bench start` for a four-pass stationary/motion A/B capture.
|
||||
|
||||

|
||||
|
||||
|
|
@ -174,6 +202,47 @@ Toggle the overlay with `/rpf overlay`, or from the Profiler settings page.
|
|||
|
||||
---
|
||||
|
||||
## Misc Tweaks
|
||||
|
||||
After a short cancellable countdown on the main menu, load the most recent save.
|
||||
Any key cancels. Enable it from the S³ settings page.
|
||||
|
||||
---
|
||||
|
||||
## Quick Actions
|
||||
|
||||
Extends the rolling-stock radial menu with outer-ring end actions (couple, air,
|
||||
angle cock, cut) and a hover Consist wheel for train-wide operations (set lead,
|
||||
handbrakes, bleed, air, idle, select loco). Cut can optionally apply a handbrake.
|
||||
|
||||
---
|
||||
|
||||
## Car Cards
|
||||
|
||||
A Monopoly-style fanned card dock for the coupled cut around your selected car.
|
||||
Color bands, waybill info, per-car notes, and couple/handbrake/locate actions.
|
||||
Optional WaypointQueue cut dividers when that mod is installed.
|
||||
|
||||
---
|
||||
|
||||
## Industry Tags
|
||||
|
||||
Floating in-world callouts for businesses, per-track badges, and yard codes,
|
||||
read from live industry data so mod maps are included. Double-click centers
|
||||
the strategy camera. Hover highlights the related tracks. Console: `/s3ind dump`.
|
||||
|
||||
---
|
||||
|
||||
## MCP
|
||||
|
||||
A loopback-only HTTP server so a local coding agent can observe the live game
|
||||
and, with gates on, control it or reload tool DLLs. Enable it from the S³
|
||||
settings page. Default bind is 127.0.0.1. Console: `/s3mcp`.
|
||||
|
||||
This is a developer module. Leave it off unless you are driving S³ from an agent.
|
||||
|
||||
---
|
||||
|
||||
## Migrating from the standalone mods
|
||||
|
||||
S³ replaces the separate **Physics Optimizer** (`RailroaderPhysicsOverhaul`) and
|
||||
|
|
|
|||
74
dist/build-common.ps1
vendored
74
dist/build-common.ps1
vendored
|
|
@ -28,6 +28,17 @@ function Invoke-ManagedBuild {
|
|||
return $dll
|
||||
}
|
||||
|
||||
function Invoke-McpToolsBuild {
|
||||
param([string]$Configuration = "Release", [string]$GameDir = "")
|
||||
|
||||
Write-Host "=== Building MCP tools (S3.Mcp.Tools.dll, $Configuration) ===" -ForegroundColor Cyan
|
||||
$csproj = Join-Path $RepoRoot "mcp-tools\S3.Mcp.Tools.csproj"
|
||||
$args = @($csproj, "-c", $Configuration, "--nologo", "-v", "minimal")
|
||||
if ($GameDir) { $args += "/p:GameDir=$GameDir" }
|
||||
dotnet build @args | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "MCP tools build failed." }
|
||||
}
|
||||
|
||||
function Invoke-NativeBuild {
|
||||
param([string]$Configuration = "Release")
|
||||
|
||||
|
|
@ -50,6 +61,51 @@ function Invoke-NativeBuild {
|
|||
return $dll
|
||||
}
|
||||
|
||||
function Test-ModDllLocked {
|
||||
param([Parameter(Mandatory)][string]$Path)
|
||||
if (-not (Test-Path $Path)) { return $false }
|
||||
try {
|
||||
[System.IO.File]::Open($Path, 'Open', 'ReadWrite', 'None').Close()
|
||||
return $false
|
||||
} catch {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
function Get-LockedModDlls {
|
||||
param([Parameter(Mandatory)][string]$DestModDir)
|
||||
$locked = @()
|
||||
foreach ($name in @("S3.dll", "S3Native.dll")) {
|
||||
if (Test-ModDllLocked (Join-Path $DestModDir $name)) { $locked += $name }
|
||||
}
|
||||
return $locked
|
||||
}
|
||||
|
||||
# Poll until Mods\S3 DLLs are writable. Railroader.exe is the usual locker;
|
||||
# we wait on the file itself so a hung Unity process is still detected.
|
||||
function Wait-ModUnlocked {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$DestModDir,
|
||||
[int]$PollSeconds = 2
|
||||
)
|
||||
$locked = @(Get-LockedModDlls $DestModDir)
|
||||
if ($locked.Count -eq 0) { return }
|
||||
|
||||
Write-Host "=== Waiting for Railroader to close ($($locked -join ', ') locked) ===" -ForegroundColor Yellow
|
||||
Write-Host " Close the game; install will run automatically. Ctrl+C to abort." -ForegroundColor DarkYellow
|
||||
while ($true) {
|
||||
Start-Sleep -Seconds $PollSeconds
|
||||
$still = @(Get-LockedModDlls $DestModDir)
|
||||
if ($still.Count -eq 0) {
|
||||
Write-Host "=== Unlocked, installing ===" -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
$rr = Get-Process -Name "Railroader" -ErrorAction SilentlyContinue
|
||||
$hint = if ($rr) { "Railroader.exe still running" } else { "$($still -join ', ') still locked" }
|
||||
Write-Host " ... $hint" -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
|
||||
# Assembles the Mods\S3 layout into $DestModDir (the S3 folder itself).
|
||||
#
|
||||
# Overwrites in place rather than wiping the folder. A full wipe is dangerous: if
|
||||
|
|
@ -60,18 +116,20 @@ function Invoke-NativeBuild {
|
|||
function New-ModLayout {
|
||||
param([Parameter(Mandatory)][string]$DestModDir,
|
||||
[Parameter(Mandatory)][string]$ManagedDll,
|
||||
[string]$NativeDll = $null)
|
||||
[string]$NativeDll = $null,
|
||||
[switch]$Wait)
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $DestModDir | Out-Null
|
||||
|
||||
if ($Wait) {
|
||||
Wait-ModUnlocked -DestModDir $DestModDir
|
||||
}
|
||||
|
||||
# Fail fast with a clear message if a DLL we're about to overwrite is locked
|
||||
# (game still running) — before touching anything.
|
||||
foreach ($name in @("S3.dll", "S3Native.dll")) {
|
||||
$path = Join-Path $DestModDir $name
|
||||
if (Test-Path $path) {
|
||||
try { [System.IO.File]::Open($path, 'Open', 'ReadWrite', 'None').Close() }
|
||||
catch { throw "$name is locked - close Railroader before installing." }
|
||||
}
|
||||
$locked = @(Get-LockedModDlls $DestModDir)
|
||||
if ($locked.Count -gt 0) {
|
||||
throw "$($locked -join ', ') is locked - close Railroader before installing (or rerun with -Wait)."
|
||||
}
|
||||
|
||||
# Drop stale artifacts from older builds (the pre-rename native DLL, ngen
|
||||
|
|
@ -82,6 +140,8 @@ function New-ModLayout {
|
|||
|
||||
Copy-Item (Join-Path $RepoRoot "Info.json") (Join-Path $DestModDir "Info.json") -Force
|
||||
Copy-Item $ManagedDll (Join-Path $DestModDir "S3.dll") -Force
|
||||
$icon = Join-Path $RepoRoot "src\Modules\QuickActions\Icons\consist.png"
|
||||
if (Test-Path $icon) { Copy-Item $icon (Join-Path $DestModDir "consist.png") -Force }
|
||||
if ($NativeDll) {
|
||||
Copy-Item $NativeDll (Join-Path $DestModDir "S3Native.dll") -Force
|
||||
# Ship ProggyClean.ttf alongside the native DLL so IMGUI_DISABLE_DEFAULT_FONT
|
||||
|
|
|
|||
22
dist/build-local.ps1
vendored
22
dist/build-local.ps1
vendored
|
|
@ -1,20 +1,38 @@
|
|||
# Build S³ and install it straight into the local game copy for testing.
|
||||
# .\dist\build-local.ps1
|
||||
# .\dist\build-local.ps1 -Wait
|
||||
# .\dist\build-local.ps1 -ToolsOnly
|
||||
# .\dist\build-local.ps1 -GameDir "X:\path\to\Railroader" -Configuration Debug
|
||||
#
|
||||
# -Wait: build now, then poll until Railroader releases Mods\S3\*.dll and copy.
|
||||
# Use this when the game is open (S3Native.dll stays locked until quit).
|
||||
#
|
||||
# -ToolsOnly: rebuild the reloadable MCP tools DLL and copy it to
|
||||
# Mods\S3\plugins\ while the game is running. The MCP host auto-reloads it.
|
||||
|
||||
param(
|
||||
[string]$Configuration = "Release",
|
||||
[string]$GameDir = "D:\Seton\SteamApps\steamapps\common\Railroader"
|
||||
[string]$GameDir = "D:\Seton\SteamApps\steamapps\common\Railroader",
|
||||
[switch]$Wait,
|
||||
[switch]$ToolsOnly
|
||||
)
|
||||
|
||||
. (Join-Path $PSScriptRoot "build-common.ps1")
|
||||
|
||||
if (-not (Test-Path $GameDir)) { throw "GameDir not found: $GameDir" }
|
||||
|
||||
if ($ToolsOnly) {
|
||||
Invoke-McpToolsBuild -Configuration $Configuration -GameDir $GameDir
|
||||
Write-Host "=== MCP tools copied to $GameDir\Mods\S3\plugins ===" -ForegroundColor Green
|
||||
Write-Host " Host auto-reloads S3.Mcp.Tools.dll (or /s3mcp reload)."
|
||||
return
|
||||
}
|
||||
|
||||
$managed = Invoke-ManagedBuild -Configuration $Configuration -GameDir $GameDir
|
||||
$native = Invoke-NativeBuild -Configuration $Configuration
|
||||
|
||||
$modDir = Join-Path $GameDir "Mods\$ModId"
|
||||
New-ModLayout -DestModDir $modDir -ManagedDll $managed -NativeDll $native
|
||||
New-ModLayout -DestModDir $modDir -ManagedDll $managed -NativeDll $native -Wait:$Wait
|
||||
Invoke-McpToolsBuild -Configuration $Configuration -GameDir $GameDir
|
||||
|
||||
Write-Host "=== Installed to $modDir ===" -ForegroundColor Green
|
||||
|
|
|
|||
7
dist/build-mcp-tools.ps1
vendored
Normal file
7
dist/build-mcp-tools.ps1
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Rebuild only the reloadable MCP tools pack (game can stay open).
|
||||
# .\dist\build-mcp-tools.ps1
|
||||
param(
|
||||
[string]$Configuration = "Release",
|
||||
[string]$GameDir = "D:\Seton\SteamApps\steamapps\common\Railroader"
|
||||
)
|
||||
& (Join-Path $PSScriptRoot "build-local.ps1") -Configuration $Configuration -GameDir $GameDir -ToolsOnly
|
||||
241
mcp-tools/DeepMethodProfiler.cs
Normal file
241
mcp-tools/DeepMethodProfiler.cs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using HarmonyLib;
|
||||
using S3.Modules.Profiler;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Mcp.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Temporary, capture-scoped timings around game MonoBehaviour lifecycle methods.
|
||||
/// This deliberately lives in the reloadable MCP tools pack so profiling coverage
|
||||
/// can be refined without restarting the game.
|
||||
/// </summary>
|
||||
internal static class DeepMethodProfiler
|
||||
{
|
||||
static readonly string[] LifecycleMethods =
|
||||
{
|
||||
"FixedUpdate",
|
||||
"Update",
|
||||
"LateUpdate",
|
||||
"OnPreCull",
|
||||
"OnPreRender",
|
||||
"OnPostRender",
|
||||
"OnWillRenderObject",
|
||||
};
|
||||
|
||||
static readonly string[] ExcludedAssemblyPrefixes =
|
||||
{
|
||||
"System",
|
||||
"Microsoft",
|
||||
"Mono.",
|
||||
"mscorlib",
|
||||
"netstandard",
|
||||
"0Harmony",
|
||||
"Newtonsoft.",
|
||||
"UnityModManager",
|
||||
"S3",
|
||||
};
|
||||
|
||||
static readonly Dictionary<MethodBase, string> ProbeIds = new();
|
||||
static Harmony? _harmony;
|
||||
|
||||
public static bool Active => _harmony != null;
|
||||
public static int PatchedMethods => ProbeIds.Count;
|
||||
|
||||
public static bool Start(out string details)
|
||||
{
|
||||
if (_harmony != null)
|
||||
{
|
||||
details = $"already active ({ProbeIds.Count} methods)";
|
||||
return true;
|
||||
}
|
||||
|
||||
string harmonyId =
|
||||
"S3.mcp.deep." + typeof(DeepMethodProfiler).Assembly.GetName().Name;
|
||||
var harmony = new Harmony(harmonyId);
|
||||
var prefix = new HarmonyMethod(
|
||||
typeof(DeepMethodProfiler), nameof(Prefix));
|
||||
var postfix = new HarmonyMethod(
|
||||
typeof(DeepMethodProfiler), nameof(Postfix));
|
||||
int patched = 0;
|
||||
int lifecyclePatched = 0;
|
||||
int stateMachinePatched = 0;
|
||||
int failed = 0;
|
||||
|
||||
foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
if (!IsCandidateAssembly(assembly)) continue;
|
||||
foreach (Type type in GetTypesSafely(assembly))
|
||||
{
|
||||
if (type == null || type.IsAbstract) continue;
|
||||
if (typeof(MonoBehaviour).IsAssignableFrom(type))
|
||||
{
|
||||
for (int i = 0; i < LifecycleMethods.Length; i++)
|
||||
{
|
||||
MethodInfo? method = FindParameterlessMethod(
|
||||
type, LifecycleMethods[i]);
|
||||
if (!CanPatch(method)) continue;
|
||||
if (TryPatch(
|
||||
harmony, method!, prefix, postfix,
|
||||
$"deep.{method!.Name}:{DisplayTypeName(type)}"))
|
||||
{
|
||||
patched++;
|
||||
lifecyclePatched++;
|
||||
}
|
||||
else failed++;
|
||||
}
|
||||
}
|
||||
|
||||
bool coroutine = typeof(IEnumerator).IsAssignableFrom(type);
|
||||
bool asyncStateMachine =
|
||||
typeof(IAsyncStateMachine).IsAssignableFrom(type);
|
||||
if (coroutine || asyncStateMachine)
|
||||
{
|
||||
MethodInfo? moveNext = FindParameterlessMethod(type, "MoveNext");
|
||||
if (!CanPatch(moveNext)) continue;
|
||||
string kind = coroutine ? "Coroutine" : "Async";
|
||||
if (TryPatch(
|
||||
harmony, moveNext!, prefix, postfix,
|
||||
$"deep.{kind}:{DisplayTypeName(type)}"))
|
||||
{
|
||||
patched++;
|
||||
stateMachinePatched++;
|
||||
}
|
||||
else failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (patched == 0)
|
||||
{
|
||||
harmony.UnpatchAll(harmonyId);
|
||||
ProbeIds.Clear();
|
||||
details = $"no methods patched (failures={failed})";
|
||||
return false;
|
||||
}
|
||||
|
||||
_harmony = harmony;
|
||||
details =
|
||||
$"patched={patched} lifecycle={lifecyclePatched} " +
|
||||
$"stateMachines={stateMachinePatched} failures={failed}";
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
Harmony? harmony = _harmony;
|
||||
_harmony = null;
|
||||
if (harmony != null)
|
||||
{
|
||||
try { harmony.UnpatchAll(harmony.Id); }
|
||||
catch { }
|
||||
}
|
||||
ProbeIds.Clear();
|
||||
}
|
||||
|
||||
static bool IsCandidateAssembly(Assembly assembly)
|
||||
{
|
||||
string name;
|
||||
try { name = assembly.GetName().Name ?? ""; }
|
||||
catch { return false; }
|
||||
if (name.Equals("S3", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
for (int i = 0; i < ExcludedAssemblyPrefixes.Length; i++)
|
||||
{
|
||||
if (name.StartsWith(
|
||||
ExcludedAssemblyPrefixes[i],
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static Type[] GetTypesSafely(Assembly assembly)
|
||||
{
|
||||
try { return assembly.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
var result = new List<Type>();
|
||||
Type?[] types = ex.Types;
|
||||
for (int i = 0; i < types.Length; i++)
|
||||
if (types[i] != null) result.Add(types[i]!);
|
||||
return result.ToArray();
|
||||
}
|
||||
catch { return Array.Empty<Type>(); }
|
||||
}
|
||||
|
||||
static MethodInfo? FindParameterlessMethod(Type type, string name)
|
||||
{
|
||||
MethodInfo[] methods;
|
||||
try
|
||||
{
|
||||
methods = type.GetMethods(
|
||||
BindingFlags.Instance |
|
||||
BindingFlags.Public |
|
||||
BindingFlags.NonPublic |
|
||||
BindingFlags.DeclaredOnly);
|
||||
}
|
||||
catch { return null; }
|
||||
|
||||
for (int i = 0; i < methods.Length; i++)
|
||||
if (methods[i].Name == name &&
|
||||
methods[i].GetParameters().Length == 0)
|
||||
return methods[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool CanPatch(MethodInfo? method)
|
||||
{
|
||||
if (method == null || method.IsAbstract ||
|
||||
method.ContainsGenericParameters)
|
||||
return false;
|
||||
try { return method.GetMethodBody() != null; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
static bool TryPatch(
|
||||
Harmony harmony,
|
||||
MethodInfo method,
|
||||
HarmonyMethod prefix,
|
||||
HarmonyMethod postfix,
|
||||
string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProbeIds[method] = id;
|
||||
harmony.Patch(method, prefix, postfix);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
ProbeIds.Remove(method);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static string DisplayTypeName(Type type)
|
||||
{
|
||||
string name = type.FullName ?? type.Name;
|
||||
Type? declaring = type.DeclaringType;
|
||||
if (declaring == null) return name;
|
||||
return (declaring.FullName ?? declaring.Name) + "." + type.Name;
|
||||
}
|
||||
|
||||
static void Prefix(out long __state)
|
||||
{
|
||||
__state = Stopwatch.GetTimestamp();
|
||||
}
|
||||
|
||||
static void Postfix(MethodBase __originalMethod, long __state)
|
||||
{
|
||||
if (__state == 0 || !SparseHitchSampler.Active) return;
|
||||
if (!ProbeIds.TryGetValue(__originalMethod, out string? id)) return;
|
||||
SparseHitchSampler.Record(id, Stopwatch.GetTimestamp() - __state);
|
||||
}
|
||||
}
|
||||
116
mcp-tools/PlayerLoopProfiler.cs
Normal file
116
mcp-tools/PlayerLoopProfiler.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
using S3.Modules.Profiler;
|
||||
using UnityEngine.LowLevel;
|
||||
|
||||
namespace S3.Mcp.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Temporarily inserts timestamp boundaries around every Unity PlayerLoop
|
||||
/// subsystem. This measures native engine phases that Harmony cannot patch.
|
||||
/// </summary>
|
||||
internal static class PlayerLoopProfiler
|
||||
{
|
||||
static PlayerLoopSystem _original;
|
||||
static long[] _depthStarts = Array.Empty<long>();
|
||||
|
||||
public static bool Active { get; private set; }
|
||||
public static int Boundaries { get; private set; }
|
||||
|
||||
public static string Start()
|
||||
{
|
||||
Stop();
|
||||
try
|
||||
{
|
||||
_original = PlayerLoop.GetCurrentPlayerLoop();
|
||||
PlayerLoopSystem instrumented = _original;
|
||||
Boundaries = 0;
|
||||
int maxDepth = MeasureDepth(instrumented, 0);
|
||||
_depthStarts = new long[Math.Max(2, maxDepth + 2)];
|
||||
Instrument(ref instrumented, 0, "PlayerLoop");
|
||||
PlayerLoop.SetPlayerLoop(instrumented);
|
||||
Active = true;
|
||||
return $"boundaries={Boundaries} depth={maxDepth}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Stop();
|
||||
return "failed:" + ex.GetType().Name;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
if (Active)
|
||||
{
|
||||
try { PlayerLoop.SetPlayerLoop(_original); }
|
||||
catch { }
|
||||
}
|
||||
Active = false;
|
||||
Boundaries = 0;
|
||||
_depthStarts = Array.Empty<long>();
|
||||
}
|
||||
|
||||
static int MeasureDepth(PlayerLoopSystem system, int depth)
|
||||
{
|
||||
int max = depth;
|
||||
PlayerLoopSystem[]? children = system.subSystemList;
|
||||
if (children == null) return max;
|
||||
for (int i = 0; i < children.Length; i++)
|
||||
max = Math.Max(max, MeasureDepth(children[i], depth + 1));
|
||||
return max;
|
||||
}
|
||||
|
||||
static void Instrument(
|
||||
ref PlayerLoopSystem system,
|
||||
int depth,
|
||||
string parentPath)
|
||||
{
|
||||
PlayerLoopSystem[]? children = system.subSystemList;
|
||||
if (children == null || children.Length == 0) return;
|
||||
|
||||
var expanded = new PlayerLoopSystem[children.Length * 2 + 1];
|
||||
expanded[0] = Boundary(depth, null);
|
||||
for (int i = 0; i < children.Length; i++)
|
||||
{
|
||||
PlayerLoopSystem child = children[i];
|
||||
string childName = DisplayName(child.type, i);
|
||||
string path = parentPath + "." + childName;
|
||||
Instrument(ref child, depth + 1, path);
|
||||
expanded[i * 2 + 1] = child;
|
||||
expanded[i * 2 + 2] = Boundary(depth, "loop." + path);
|
||||
Boundaries++;
|
||||
}
|
||||
system.subSystemList = expanded;
|
||||
}
|
||||
|
||||
static PlayerLoopSystem Boundary(int depth, string? completedId)
|
||||
{
|
||||
return new PlayerLoopSystem
|
||||
{
|
||||
type = typeof(PlayerLoopProfiler),
|
||||
updateDelegate = () => Mark(depth, completedId),
|
||||
};
|
||||
}
|
||||
|
||||
static void Mark(int depth, string? completedId)
|
||||
{
|
||||
long now = Stopwatch.GetTimestamp();
|
||||
if (completedId != null &&
|
||||
SparseHitchSampler.Active &&
|
||||
depth < _depthStarts.Length)
|
||||
{
|
||||
long start = _depthStarts[depth];
|
||||
if (start != 0 && now >= start)
|
||||
SparseHitchSampler.Record(completedId, now - start);
|
||||
}
|
||||
if (depth < _depthStarts.Length)
|
||||
_depthStarts[depth] = now;
|
||||
}
|
||||
|
||||
static string DisplayName(Type? type, int index)
|
||||
{
|
||||
if (type == null) return "unnamed" + index;
|
||||
return type.FullName ?? type.Name;
|
||||
}
|
||||
}
|
||||
60
mcp-tools/S3.Mcp.Tools.csproj
Normal file
60
mcp-tools/S3.Mcp.Tools.csproj
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>annotations</Nullable>
|
||||
<!-- Unique assembly identity every build so Unity Mono can Assembly.Load
|
||||
a new copy without colliding with the previous one. File is copied
|
||||
to the stable name S3.Mcp.Tools.dll; the host loads bytes. -->
|
||||
<ToolsStamp>$([System.DateTime]::UtcNow.ToString('yyyyMMddHHmmssfff'))</ToolsStamp>
|
||||
<AssemblyName>S3.Mcp.Tools_$(ToolsStamp)</AssemblyName>
|
||||
<RootNamespace>S3.Mcp.Tools</RootNamespace>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
<S3Dll>$(MSBuildThisFileDirectory)..\src\bin\$(Configuration)\S3.dll</S3Dll>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="S3">
|
||||
<HintPath>$(S3Dll)</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Assembly-CSharp">
|
||||
<HintPath>$(GameManaged)\Assembly-CSharp.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Newtonsoft.Json">
|
||||
<HintPath>$(GameManaged)\Newtonsoft.Json.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine">
|
||||
<HintPath>$(GameManaged)\UnityEngine.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.CoreModule">
|
||||
<HintPath>$(GameManaged)\UnityEngine.CoreModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.PhysicsModule">
|
||||
<HintPath>$(GameManaged)\UnityEngine.PhysicsModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.InputLegacyModule">
|
||||
<HintPath>$(GameManaged)\UnityEngine.InputLegacyModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="0Harmony">
|
||||
<HintPath>$(UmmDir)\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyStablePlugin" AfterTargets="Build">
|
||||
<MakeDir Directories="$(GameDir)\Mods\S3\plugins" />
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFiles="$(GameDir)\Mods\S3\plugins\S3.Mcp.Tools.dll" />
|
||||
<Message Importance="high" Text="Copied S3.Mcp.Tools.dll (assembly $(AssemblyName))" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
145
mcp-tools/SparseHitchSampler.cs
Normal file
145
mcp-tools/SparseHitchSampler.cs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using S3.Modules.Profiler;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Mcp.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Low-allocation manual sampler. It stores full probe detail only for hitch
|
||||
/// frames and ignores sub-0.1ms samples, avoiding profiler-induced GC stalls.
|
||||
/// </summary>
|
||||
internal static class SparseHitchSampler
|
||||
{
|
||||
static readonly Dictionary<string, HitchProbeSample> Current =
|
||||
new(StringComparer.Ordinal);
|
||||
static readonly List<HitchFrameRecord> Frames = new(4096);
|
||||
static GameObject? _driverObject;
|
||||
static float _threshold;
|
||||
static int _frame;
|
||||
static int _gc0;
|
||||
static int _gc1;
|
||||
static int _gc2;
|
||||
static long _mono;
|
||||
static bool _pending;
|
||||
|
||||
public static bool Active { get; private set; }
|
||||
|
||||
public static void Begin(float thresholdMs)
|
||||
{
|
||||
Cancel();
|
||||
Current.Clear();
|
||||
Frames.Clear();
|
||||
_threshold = thresholdMs;
|
||||
_frame = 0;
|
||||
_gc0 = GC.CollectionCount(0);
|
||||
_gc1 = GC.CollectionCount(1);
|
||||
_gc2 = GC.CollectionCount(2);
|
||||
_mono = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
|
||||
_pending = false;
|
||||
Active = true;
|
||||
_driverObject = new GameObject("S3 Sparse Hitch Sampler");
|
||||
_driverObject.hideFlags = HideFlags.HideAndDontSave;
|
||||
UnityEngine.Object.DontDestroyOnLoad(_driverObject);
|
||||
_driverObject.AddComponent<SparseHitchFrameDriver>();
|
||||
}
|
||||
|
||||
public static List<HitchFrameRecord> End()
|
||||
{
|
||||
Active = false;
|
||||
DestroyDriver();
|
||||
Current.Clear();
|
||||
_pending = false;
|
||||
return new List<HitchFrameRecord>(Frames);
|
||||
}
|
||||
|
||||
public static void Cancel()
|
||||
{
|
||||
Active = false;
|
||||
DestroyDriver();
|
||||
Current.Clear();
|
||||
Frames.Clear();
|
||||
_pending = false;
|
||||
}
|
||||
|
||||
public static void Record(string id, long ticks, int calls = 1)
|
||||
{
|
||||
if (!Active || ticks <= Stopwatch.Frequency / 10_000) return;
|
||||
if (!Current.TryGetValue(id, out HitchProbeSample? sample))
|
||||
{
|
||||
sample = new HitchProbeSample();
|
||||
Current[id] = sample;
|
||||
}
|
||||
sample.Calls += calls;
|
||||
sample.Ticks += ticks;
|
||||
if (ticks > sample.MaxTicks) sample.MaxTicks = ticks;
|
||||
}
|
||||
|
||||
public static void Advance(float frameMs)
|
||||
{
|
||||
if (!Active) return;
|
||||
int gc0 = GC.CollectionCount(0);
|
||||
int gc1 = GC.CollectionCount(1);
|
||||
int gc2 = GC.CollectionCount(2);
|
||||
long mono = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
|
||||
|
||||
if (_pending && frameMs > 0f && frameMs < 2000f)
|
||||
{
|
||||
var frame = new HitchFrameRecord
|
||||
{
|
||||
Phase = "manual-sparse",
|
||||
Frame = _frame++,
|
||||
FrameMs = frameMs,
|
||||
Gc0 = gc0 - _gc0,
|
||||
Gc1 = gc1 - _gc1,
|
||||
Gc2 = gc2 - _gc2,
|
||||
MonoDelta = mono - _mono,
|
||||
CameraPosition = Camera.main != null
|
||||
? Camera.main.transform.position
|
||||
: Vector3.zero,
|
||||
};
|
||||
if (frameMs >= _threshold)
|
||||
{
|
||||
foreach (var pair in Current)
|
||||
{
|
||||
HitchProbeSample sample = pair.Value;
|
||||
if (sample.Calls == 0) continue;
|
||||
frame.Probes[pair.Key] = new HitchProbeSample
|
||||
{
|
||||
Calls = sample.Calls,
|
||||
Ticks = sample.Ticks,
|
||||
MaxTicks = sample.MaxTicks,
|
||||
};
|
||||
}
|
||||
}
|
||||
Frames.Add(frame);
|
||||
}
|
||||
|
||||
foreach (HitchProbeSample sample in Current.Values)
|
||||
{
|
||||
sample.Calls = 0;
|
||||
sample.Ticks = 0;
|
||||
sample.MaxTicks = 0;
|
||||
}
|
||||
_gc0 = gc0;
|
||||
_gc1 = gc1;
|
||||
_gc2 = gc2;
|
||||
_mono = mono;
|
||||
_pending = true;
|
||||
}
|
||||
|
||||
static void DestroyDriver()
|
||||
{
|
||||
if (_driverObject == null) return;
|
||||
UnityEngine.Object.Destroy(_driverObject);
|
||||
_driverObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
[DefaultExecutionOrder(-31999)]
|
||||
public sealed class SparseHitchFrameDriver : MonoBehaviour
|
||||
{
|
||||
void Update() =>
|
||||
SparseHitchSampler.Advance(Time.unscaledDeltaTime * 1000f);
|
||||
}
|
||||
1029
mcp-tools/ToolsPlugin.cs
Normal file
1029
mcp-tools/ToolsPlugin.cs
Normal file
File diff suppressed because it is too large
Load diff
197
mcp-tools/UnityMarkerProfiler.cs
Normal file
197
mcp-tools/UnityMarkerProfiler.cs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using S3.Modules.Profiler;
|
||||
using Unity.Profiling;
|
||||
using Unity.Profiling.LowLevel.Unsafe;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Mcp.Tools;
|
||||
|
||||
/// <summary>Capture-scoped Unity PlayerLoop and subsystem marker recorders.</summary>
|
||||
internal static class UnityMarkerProfiler
|
||||
{
|
||||
sealed class Entry
|
||||
{
|
||||
public string Id = "";
|
||||
public ProfilerRecorder Recorder;
|
||||
}
|
||||
|
||||
readonly struct Marker
|
||||
{
|
||||
public readonly ProfilerCategory Category;
|
||||
public readonly string Name;
|
||||
|
||||
public Marker(ProfilerCategory category, string name)
|
||||
{
|
||||
Category = category;
|
||||
Name = name;
|
||||
}
|
||||
}
|
||||
|
||||
static readonly Marker[] CandidateMarkers =
|
||||
{
|
||||
new(ProfilerCategory.Scripts, "BehaviourUpdate"),
|
||||
new(ProfilerCategory.Scripts, "ScriptRunBehaviourUpdate"),
|
||||
new(ProfilerCategory.Scripts, "ScriptRunBehaviourFixedUpdate"),
|
||||
new(ProfilerCategory.Scripts, "ScriptRunBehaviourLateUpdate"),
|
||||
new(ProfilerCategory.Scripts, "ScriptRunDelayedTasks"),
|
||||
new(ProfilerCategory.Scripts, "CoroutinesDelayedCalls"),
|
||||
new(ProfilerCategory.Scripts, "GC.Collect"),
|
||||
new(ProfilerCategory.Scripts, "GC.Incremental.Collect"),
|
||||
|
||||
new(ProfilerCategory.Internal, "PlayerLoop"),
|
||||
new(ProfilerCategory.Internal, "EarlyUpdate"),
|
||||
new(ProfilerCategory.Internal, "FixedUpdate"),
|
||||
new(ProfilerCategory.Internal, "PreUpdate"),
|
||||
new(ProfilerCategory.Internal, "Update"),
|
||||
new(ProfilerCategory.Internal, "PreLateUpdate"),
|
||||
new(ProfilerCategory.Internal, "PostLateUpdate"),
|
||||
new(ProfilerCategory.Internal, "WaitForJobGroupID"),
|
||||
new(ProfilerCategory.Internal, "JobHandle.Complete"),
|
||||
new(ProfilerCategory.Internal, "Semaphore.WaitForSignal"),
|
||||
new(ProfilerCategory.Internal, "WaitForTargetFPS"),
|
||||
new(ProfilerCategory.Internal, "Gfx.WaitForPresentOnGfxThread"),
|
||||
new(ProfilerCategory.Internal, "Gfx.PresentFrame"),
|
||||
|
||||
new(ProfilerCategory.Loading, "PreloadManager.UpdatePreloading"),
|
||||
new(ProfilerCategory.Loading, "Application.Integrate Assets in Background"),
|
||||
new(ProfilerCategory.Loading, "AsyncUploadManager.Update"),
|
||||
new(ProfilerCategory.Loading, "Resources.UnloadUnusedAssets"),
|
||||
new(ProfilerCategory.Loading, "SceneManager.Update"),
|
||||
|
||||
new(ProfilerCategory.Render, "UpdateAllRenderers"),
|
||||
new(ProfilerCategory.Render, "UpdateRendererBoundingVolumes"),
|
||||
new(ProfilerCategory.Render, "UpdateAllSkinnedMeshes"),
|
||||
new(ProfilerCategory.Render, "Camera.Render"),
|
||||
new(ProfilerCategory.Render, "CullScriptable"),
|
||||
new(ProfilerCategory.Render, "RenderLoop.Draw"),
|
||||
new(ProfilerCategory.Render, "BatchRendererGroup"),
|
||||
|
||||
new(ProfilerCategory.Animation, "DirectorUpdateAnimationBegin"),
|
||||
new(ProfilerCategory.Animation, "DirectorUpdateAnimationEnd"),
|
||||
new(ProfilerCategory.Animation, "Animator.Update"),
|
||||
new(ProfilerCategory.Physics, "Physics.Simulate"),
|
||||
new(ProfilerCategory.Particles, "ParticleSystem.Update"),
|
||||
new(ProfilerCategory.Particles, "ParticleSystem.ScheduleGeometryJobs"),
|
||||
};
|
||||
|
||||
static readonly List<Entry> Entries = new();
|
||||
static GameObject? _driverObject;
|
||||
|
||||
public static int ActiveMarkers => Entries.Count;
|
||||
|
||||
public static string Start()
|
||||
{
|
||||
Stop();
|
||||
var names = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var available = new List<ProfilerRecorderHandle>();
|
||||
try { ProfilerRecorderHandle.GetAvailable(available); }
|
||||
catch { }
|
||||
|
||||
for (int i = 0; i < available.Count && Entries.Count < 512; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProfilerRecorderDescription description =
|
||||
ProfilerRecorderHandle.GetDescription(available[i]);
|
||||
string name = description.Name;
|
||||
if (description.UnitType !=
|
||||
ProfilerMarkerDataUnit.TimeNanoseconds)
|
||||
continue;
|
||||
AddRecorder(description.Category, name, names, seen);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// Some release players expose only a small built-in counter list.
|
||||
// Probe known marker/category pairs to supplement that list.
|
||||
for (int i = 0;
|
||||
i < CandidateMarkers.Length && Entries.Count < 512;
|
||||
i++)
|
||||
{
|
||||
Marker marker = CandidateMarkers[i];
|
||||
AddRecorder(marker.Category, marker.Name, names, seen);
|
||||
}
|
||||
|
||||
if (Entries.Count > 0)
|
||||
{
|
||||
_driverObject = new GameObject("S3 Deep Unity Marker Profiler");
|
||||
_driverObject.hideFlags = HideFlags.HideAndDontSave;
|
||||
UnityEngine.Object.DontDestroyOnLoad(_driverObject);
|
||||
_driverObject.AddComponent<UnityMarkerFrameDriver>();
|
||||
}
|
||||
return Entries.Count == 0
|
||||
? "markers=0"
|
||||
: $"available={available.Count} markers={Entries.Count} " +
|
||||
$"[{string.Join(", ", names.GetRange(0, Math.Min(20, names.Count)))}" +
|
||||
(names.Count > 20 ? ", ...]" : "]");
|
||||
}
|
||||
|
||||
static void AddRecorder(
|
||||
ProfilerCategory category,
|
||||
string name,
|
||||
List<string> names,
|
||||
HashSet<string> seen)
|
||||
{
|
||||
string unique = category.Name + ":" + name;
|
||||
if (!seen.Add(unique)) return;
|
||||
try
|
||||
{
|
||||
ProfilerRecorder recorder = ProfilerRecorder.StartNew(
|
||||
category,
|
||||
name,
|
||||
1,
|
||||
ProfilerRecorderOptions.StartImmediately |
|
||||
ProfilerRecorderOptions.SumAllSamplesInFrame);
|
||||
if (!recorder.Valid)
|
||||
{
|
||||
recorder.Dispose();
|
||||
return;
|
||||
}
|
||||
string id = "unity." + category.Name + ":" + name;
|
||||
Entries.Add(new Entry { Id = id, Recorder = recorder });
|
||||
names.Add(unique);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static void Stop()
|
||||
{
|
||||
if (_driverObject != null)
|
||||
{
|
||||
UnityEngine.Object.Destroy(_driverObject);
|
||||
_driverObject = null;
|
||||
}
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
try { Entries[i].Recorder.Dispose(); }
|
||||
catch { }
|
||||
}
|
||||
Entries.Clear();
|
||||
}
|
||||
|
||||
public static void Sample()
|
||||
{
|
||||
if (!SparseHitchSampler.Active) return;
|
||||
for (int i = 0; i < Entries.Count; i++)
|
||||
{
|
||||
Entry entry = Entries[i];
|
||||
long nanoseconds;
|
||||
try { nanoseconds = entry.Recorder.LastValue; }
|
||||
catch { continue; }
|
||||
if (nanoseconds <= 0 || nanoseconds > 10_000_000_000L)
|
||||
continue;
|
||||
long ticks = (long)(
|
||||
nanoseconds * (double)Stopwatch.Frequency / 1_000_000_000.0);
|
||||
SparseHitchSampler.Record(entry.Id, ticks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DefaultExecutionOrder(31900)]
|
||||
public sealed class UnityMarkerFrameDriver : MonoBehaviour
|
||||
{
|
||||
void LateUpdate() => UnityMarkerProfiler.Sample();
|
||||
}
|
||||
|
|
@ -67,6 +67,29 @@ enum UICmd : int32_t {
|
|||
TrackLabelSetAllZoom = 45, // y = orthographicSize beyond which ALL labels hide
|
||||
ToggleAvoidTrackLabels = 46, // push labels off their own track line
|
||||
TrackLabelSetFontSizeMin = 47, // y = minimum font size px [4, max]
|
||||
PresetAdd = 48, // save current camera as a new view preset
|
||||
PresetApply = 49, // jump to preset; y = 0-based index
|
||||
PresetDelete = 50, // delete preset; y = 0-based index
|
||||
PresetRename = 51, // rename preset; y = index, name via GetPresetRenameName
|
||||
PresetPreview = 52, // stash current view and jump to preset; y = index
|
||||
PresetCommitEdit = 53, // write current camera into preset and restore stash
|
||||
PresetCancelEdit = 54, // restore stash without saving camera
|
||||
ToggleWaypoints = 55, // toggle AE waypoint pins on the map
|
||||
ToggleWaypointsSelectedOnly = 56, // filter waypoint pins to the selected loco
|
||||
ToggleRadio = 57, // radio-control map mode
|
||||
RadioPin = 58, // pin currently selected consist loco
|
||||
RadioSelect = 59, // select pinned loco; y = index
|
||||
RadioUnpin = 60, // unpin; y = index
|
||||
RadioRename = 61, // rename pin; y = index
|
||||
RadioSetTool = 62, // y = 0 idle, 1 waypoint-place mode
|
||||
RadioSetForward = 63, // y = 0 reverse, 1 forward
|
||||
RadioSetSpeed = 64, // y = mph
|
||||
RadioStop = 65, // AE Off on selected pin
|
||||
RadioFollow = 66, // follow selected pin
|
||||
RadioJump = 67, // jump map to pin; y = index
|
||||
RadioWpChoose = 68, // y = 0 Go, 1 Couple, 2 Pickup, 3 Dropoff, 4 Cut
|
||||
RadioWpCount = 69, // y = car count (>= 1)
|
||||
RadioWpCancel = 70, // close the waypoint order popup
|
||||
};
|
||||
|
||||
// Bit indices for ME bool settings packed into PopoutWindow::imMEFlags.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
#include "d3d11_renderer.h"
|
||||
#include "popout_window.h"
|
||||
#include "popout_windows.h"
|
||||
|
|
@ -50,6 +53,8 @@ static std::atomic<int> g_currentThemePreset {0};
|
|||
static std::atomic<float> g_ovAlpha {1.0f}; // chrome (window + toolbar + compass) alpha
|
||||
static std::atomic<float> g_mapAlpha {1.0f}; // map Image() alpha, independent of chrome
|
||||
static std::atomic<float> g_mapBgAlpha {1.0f}; // camera clear colour opacity
|
||||
static std::atomic<bool> g_ovPieBlock {false}; // dim + no mouse while map pie is open
|
||||
static constexpr float kPieDim = 0.4f; // keep this fraction of current alpha
|
||||
|
||||
// cbuffer layout — must be 16-byte aligned
|
||||
struct alignas(16) UVRectCB { float u0, v0, u1, v1; };
|
||||
|
|
@ -236,6 +241,51 @@ static MapThemeData SnapshotTheme() {
|
|||
return g_theme;
|
||||
}
|
||||
|
||||
// Shared ImGui keyboard feed for overlay (Unity) and popout (Win32).
|
||||
// keyDown bits: 0 Backspace, 1 Delete, 2 Enter, 3 Escape, 4 Left, 5 Right,
|
||||
// 6 Home, 7 End, 8 Tab, 9 A, 10 C, 11 V, 12 X.
|
||||
// mods: 1 Ctrl, 2 Shift, 4 Alt.
|
||||
static void FeedImGuiKeys(ImGuiIO& io, const char* utf8, uint32_t down, uint32_t mods,
|
||||
uint32_t& prevDown, uint32_t& prevMods)
|
||||
{
|
||||
auto edge = [&](ImGuiKey key, bool now, bool was) {
|
||||
if (now != was) io.AddKeyEvent(key, now);
|
||||
};
|
||||
const bool ctrl = (mods & 1u) != 0, shift = (mods & 2u) != 0, alt = (mods & 4u) != 0;
|
||||
const bool pctrl = (prevMods & 1u) != 0, pshift = (prevMods & 2u) != 0, palt = (prevMods & 4u) != 0;
|
||||
edge(ImGuiKey_LeftCtrl, ctrl, pctrl);
|
||||
edge(ImGuiKey_RightCtrl, ctrl, pctrl);
|
||||
edge(ImGuiKey_ModCtrl, ctrl, pctrl);
|
||||
edge(ImGuiKey_LeftShift, shift, pshift);
|
||||
edge(ImGuiKey_ModShift, shift, pshift);
|
||||
edge(ImGuiKey_LeftAlt, alt, palt);
|
||||
edge(ImGuiKey_ModAlt, alt, palt);
|
||||
|
||||
struct Map { uint32_t bit; ImGuiKey key; };
|
||||
const Map map[] = {
|
||||
{ 1u << 0, ImGuiKey_Backspace },
|
||||
{ 1u << 1, ImGuiKey_Delete },
|
||||
{ 1u << 2, ImGuiKey_Enter },
|
||||
{ 1u << 3, ImGuiKey_Escape },
|
||||
{ 1u << 4, ImGuiKey_LeftArrow },
|
||||
{ 1u << 5, ImGuiKey_RightArrow },
|
||||
{ 1u << 6, ImGuiKey_Home },
|
||||
{ 1u << 7, ImGuiKey_End },
|
||||
{ 1u << 8, ImGuiKey_Tab },
|
||||
{ 1u << 9, ImGuiKey_A },
|
||||
{ 1u << 10, ImGuiKey_C },
|
||||
{ 1u << 11, ImGuiKey_V },
|
||||
{ 1u << 12, ImGuiKey_X },
|
||||
};
|
||||
for (const auto& m : map)
|
||||
edge(m.key, (down & m.bit) != 0, (prevDown & m.bit) != 0);
|
||||
|
||||
prevDown = down;
|
||||
prevMods = mods;
|
||||
if (utf8 && utf8[0])
|
||||
io.AddInputCharactersUTF8(utf8);
|
||||
}
|
||||
|
||||
// Default S3 Dark theme — identical to what InitImGui used to hardcode.
|
||||
static const MapThemeData kS3DarkTheme = {
|
||||
0.12f, 0.12f, 0.12f, 1.00f, // windowBg
|
||||
|
|
@ -306,6 +356,10 @@ static void InitImGui(ID3D11Device* device, ID3D11DeviceContext* context) {
|
|||
static const ImWchar kGlyphRanges[] = {
|
||||
0x2699, 0x2699, // ⚙ gear (settings button)
|
||||
0x25CE, 0x25CE, // ◎ bullseye (follow-player button)
|
||||
0x270E, 0x270E, // ✎ pencil (preset edit)
|
||||
0x2715, 0x2715, // ✕ delete
|
||||
0x2316, 0x2316, // ⌖ pin (preset preview)
|
||||
0x2713, 0x2713, // ✓ check (preset commit)
|
||||
0 };
|
||||
io.Fonts->AddFontFromFileTTF("C:\\Windows\\Fonts\\seguisym.ttf",
|
||||
14.f, &fc, kGlyphRanges);
|
||||
|
|
@ -647,6 +701,509 @@ static void DrawTrackLabels(PopoutWindow* win, ImDrawList* dl,
|
|||
}
|
||||
}
|
||||
|
||||
// Left-side named camera bookmarks. Last row is always "+". Edit shows an
|
||||
// InputText + pin (preview) / check (commit). Trash opens a confirm popup.
|
||||
static void DrawPresetRail(PopoutWindow* win, ImVec2 origin, float w, float h,
|
||||
float barH, const MapThemeData& theme, float mapAlpha) {
|
||||
auto pushCmd = [&](UICmd cmd, float idx = 0.f) {
|
||||
InputEvent ev{}; ev.type = UICommand;
|
||||
ev.x = static_cast<float>(cmd); ev.y = idx;
|
||||
win->inputQueue.push(ev);
|
||||
};
|
||||
|
||||
std::vector<PopoutWindow::ImMenuItem> presets;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(win->imPresetMutex);
|
||||
presets = win->imPresetList;
|
||||
}
|
||||
|
||||
const float kPad = 6.f;
|
||||
const float kRowH = 22.f;
|
||||
const float kBtnW = 20.f;
|
||||
const float kRailW = 168.f;
|
||||
const float kMaxH = std::max(kRowH + 8.f, h - barH - kPad * 2.f);
|
||||
const int n = (int)presets.size();
|
||||
const float contentH = (n + 1) * (kRowH + 2.f) + 8.f;
|
||||
const float winH = std::min(contentH, kMaxH);
|
||||
const bool scroll = contentH > kMaxH + 0.5f;
|
||||
|
||||
if (h < barH + kRowH + kPad * 2.f) return;
|
||||
|
||||
auto col32 = [&](float r, float g, float b, float a) -> ImU32 {
|
||||
return IM_COL32((int)(r*255), (int)(g*255), (int)(b*255), (int)(a * mapAlpha * 255));
|
||||
};
|
||||
ImVec4 winBg(theme.wBgR, theme.wBgG, theme.wBgB, theme.wBgA * mapAlpha);
|
||||
ImVec4 acc (theme.accR, theme.accG, theme.accB, theme.accA);
|
||||
ImVec4 txt (theme.txtR, theme.txtG, theme.txtB, theme.txtA);
|
||||
|
||||
ImGui::SetNextWindowPos(ImVec2(origin.x + kPad, origin.y + kPad), ImGuiCond_Always);
|
||||
ImGui::SetNextWindowSize(ImVec2(kRailW, winH));
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, winBg);
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, acc);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, txt);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.f, 4.f));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(3.f, 2.f));
|
||||
ImGui::Begin("##preset_rail", nullptr,
|
||||
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings |
|
||||
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoFocusOnAppearing |
|
||||
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
|
||||
ImGuiWindowFlags childFlags = ImGuiWindowFlags_NoSavedSettings;
|
||||
if (scroll) childFlags |= ImGuiWindowFlags_AlwaysVerticalScrollbar;
|
||||
ImGui::BeginChild("##preset_scroll", ImVec2(0.f, 0.f), false, childFlags);
|
||||
|
||||
int editIdx = win->imPresetEditIndex.load();
|
||||
bool preview = win->imPresetPreviewing.load();
|
||||
bool openDel = false;
|
||||
static int s_focusEdit = -1;
|
||||
if (editIdx < 0) s_focusEdit = -1;
|
||||
|
||||
if (ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows) &&
|
||||
ImGui::IsKeyPressed(ImGuiKey_Escape) && editIdx >= 0) {
|
||||
pushCmd(UICmd::PresetCancelEdit);
|
||||
win->imPresetEditIndex.store(-1);
|
||||
win->imPresetPreviewing.store(false);
|
||||
editIdx = -1;
|
||||
preview = false;
|
||||
}
|
||||
|
||||
const float nameW = ImGui::GetContentRegionAvail().x - (kBtnW + 3.f) * 2.f;
|
||||
|
||||
for (int i = 0; i < n; ++i) {
|
||||
ImGui::PushID(i);
|
||||
bool editing = (editIdx == i);
|
||||
|
||||
if (editing) {
|
||||
if (s_focusEdit != i) {
|
||||
ImGui::SetKeyboardFocusHere();
|
||||
s_focusEdit = i;
|
||||
}
|
||||
ImGui::SetNextItemWidth(nameW);
|
||||
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(theme.popR, theme.popG, theme.popB, theme.popA));
|
||||
if (ImGui::InputText("##rn", win->imPresetRenameBuf, sizeof(win->imPresetRenameBuf),
|
||||
ImGuiInputTextFlags_EnterReturnsTrue |
|
||||
ImGuiInputTextFlags_AutoSelectAll))
|
||||
pushCmd(UICmd::PresetRename, (float)i);
|
||||
if (ImGui::IsItemDeactivatedAfterEdit())
|
||||
pushCmd(UICmd::PresetRename, (float)i);
|
||||
ImGui::PopStyleColor();
|
||||
} else {
|
||||
if (ImGui::Button(presets[i].label, ImVec2(nameW, kRowH - 4.f)))
|
||||
pushCmd(UICmd::PresetApply, (float)i);
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("Jump to this view");
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
if (editing) {
|
||||
if (preview) {
|
||||
if (ImGui::Button("\xe2\x9c\x93##ok", ImVec2(kBtnW, kRowH - 4.f))) {
|
||||
pushCmd(UICmd::PresetCommitEdit, (float)i);
|
||||
win->imPresetEditIndex.store(-1);
|
||||
win->imPresetPreviewing.store(false);
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("Save this view to the preset\nand return to where you were");
|
||||
} else {
|
||||
if (ImGui::Button("\xe2\x8c\x96##pin", ImVec2(kBtnW, kRowH - 4.f))) {
|
||||
pushCmd(UICmd::PresetPreview, (float)i);
|
||||
win->imPresetPreviewing.store(true);
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("Show this preset's view\nthen adjust and confirm");
|
||||
}
|
||||
} else {
|
||||
if (ImGui::Button("\xe2\x9c\x8e##ed", ImVec2(kBtnW, kRowH - 4.f))) {
|
||||
if (editIdx >= 0 && preview)
|
||||
pushCmd(UICmd::PresetCancelEdit);
|
||||
strncpy_s(win->imPresetRenameBuf, presets[i].label, _TRUNCATE);
|
||||
win->imPresetEditIndex.store(i);
|
||||
win->imPresetPreviewing.store(false);
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("Rename or recapture this view");
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("\xe2\x9c\x95##del", ImVec2(kBtnW, kRowH - 4.f))) {
|
||||
win->imPresetPendingDelete.store(i);
|
||||
openDel = true;
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("Delete this preset");
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
if (openDel)
|
||||
ImGui::OpenPopup("##delPreset");
|
||||
|
||||
if (ImGui::Button("+##addpreset", ImVec2(nameW, kRowH - 4.f))) {
|
||||
if (editIdx >= 0 && preview)
|
||||
pushCmd(UICmd::PresetCancelEdit);
|
||||
win->imPresetEditIndex.store(-1);
|
||||
win->imPresetPreviewing.store(false);
|
||||
pushCmd(UICmd::PresetAdd);
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("Save the current view as a preset");
|
||||
|
||||
int pendingDel = win->imPresetPendingDelete.load();
|
||||
if (pendingDel >= 0 && pendingDel < n) {
|
||||
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||||
ImGui::PushStyleColor(ImGuiCol_PopupBg, ImVec4(theme.popR, theme.popG, theme.popB, theme.popA));
|
||||
if (ImGui::BeginPopupModal("##delPreset", nullptr,
|
||||
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar |
|
||||
ImGuiWindowFlags_NoSavedSettings)) {
|
||||
ImGui::Text("Delete \"%s\"?", presets[pendingDel].label);
|
||||
ImGui::Spacing();
|
||||
if (ImGui::Button("Delete", ImVec2(70.f, 0.f))) {
|
||||
if (editIdx == pendingDel && preview)
|
||||
pushCmd(UICmd::PresetCancelEdit);
|
||||
pushCmd(UICmd::PresetDelete, (float)pendingDel);
|
||||
win->imPresetPendingDelete.store(-1);
|
||||
if (editIdx == pendingDel) {
|
||||
win->imPresetEditIndex.store(-1);
|
||||
win->imPresetPreviewing.store(false);
|
||||
}
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Cancel", ImVec2(70.f, 0.f))) {
|
||||
win->imPresetPendingDelete.store(-1);
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
|
||||
ImGui::EndChild();
|
||||
ImGui::End();
|
||||
ImGui::PopStyleVar(3);
|
||||
ImGui::PopStyleColor(3);
|
||||
(void)col32; (void)w;
|
||||
}
|
||||
|
||||
// Right-side pinned radio locos. Compact rows until selected; selected row
|
||||
// expands with AE drive + waypoint-place mode (WQ orders come from a cursor popup).
|
||||
static bool DrawRadioRail(PopoutWindow* win, ImVec2 origin, float w, float h,
|
||||
float barH, const MapThemeData& theme, float mapAlpha,
|
||||
bool embedded) {
|
||||
auto pushCmd = [&](UICmd cmd, float idx = 0.f) {
|
||||
InputEvent ev{}; ev.type = UICommand;
|
||||
ev.x = static_cast<float>(cmd); ev.y = idx;
|
||||
win->inputQueue.push(ev);
|
||||
};
|
||||
|
||||
std::vector<PopoutWindow::ImMenuItem> pins;
|
||||
std::vector<uint32_t> colors;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(win->imRadioMutex);
|
||||
pins = win->imRadioList;
|
||||
colors = win->imRadioColors;
|
||||
}
|
||||
|
||||
const float kPad = 6.f;
|
||||
const float kRowH = 22.f;
|
||||
const float kBtnW = 20.f;
|
||||
const float kRailW = 188.f;
|
||||
const float kExtra = 78.f;
|
||||
const int n = (int)pins.size();
|
||||
int selected = win->imRadioSelected.load();
|
||||
if (selected < 0 || selected >= n) selected = -1;
|
||||
const float extraH = (selected >= 0) ? kExtra : 0.f;
|
||||
const float contentH = kRowH + 6.f + n * (kRowH + 2.f) + extraH + (kRowH + 2.f) + 10.f;
|
||||
const float kMaxH = std::max(kRowH * 2.f + 8.f, h - barH - kPad * 2.f);
|
||||
const float winH = std::min(contentH, kMaxH);
|
||||
const bool scroll = contentH > kMaxH + 0.5f;
|
||||
|
||||
if (h < barH + kRowH * 2.f + kPad * 2.f) return false;
|
||||
|
||||
auto col32 = [&](float r, float g, float b, float a) -> ImU32 {
|
||||
return IM_COL32((int)(r*255), (int)(g*255), (int)(b*255), (int)(a * mapAlpha * 255));
|
||||
};
|
||||
ImVec4 winBg(theme.wBgR, theme.wBgG, theme.wBgB, theme.wBgA * mapAlpha);
|
||||
ImVec4 acc (theme.accR, theme.accG, theme.accB, theme.accA);
|
||||
ImVec4 txt (theme.txtR, theme.txtG, theme.txtB, theme.txtA);
|
||||
ImVec4 accOn(std::min(1.f, acc.x * 1.4f), std::min(1.f, acc.y * 1.4f),
|
||||
std::min(1.f, acc.z * 1.4f), acc.w);
|
||||
|
||||
if (!embedded) {
|
||||
ImGui::SetNextWindowPos(ImVec2(origin.x + w - kRailW - kPad, origin.y + kPad), ImGuiCond_Always);
|
||||
ImGui::SetNextWindowSize(ImVec2(kRailW, winH));
|
||||
}
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, winBg);
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, winBg);
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, acc);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, txt);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.f, 4.f));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(3.f, 2.f));
|
||||
if (embedded) {
|
||||
ImGui::SetCursorScreenPos(ImVec2(origin.x + w - kRailW - kPad, origin.y + kPad));
|
||||
ImGui::BeginChild("##radio_rail", ImVec2(kRailW, winH), true,
|
||||
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoScrollbar |
|
||||
ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoSavedSettings);
|
||||
} else {
|
||||
ImGui::Begin("##radio_rail", nullptr,
|
||||
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings |
|
||||
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoFocusOnAppearing |
|
||||
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
}
|
||||
|
||||
ImGuiWindowFlags childFlags = ImGuiWindowFlags_NoSavedSettings;
|
||||
if (scroll) childFlags |= ImGuiWindowFlags_AlwaysVerticalScrollbar;
|
||||
ImGui::BeginChild("##radio_scroll", ImVec2(0.f, 0.f), false, childFlags);
|
||||
|
||||
bool radioOn = win->imRadioOn.load();
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, radioOn ? accOn : acc);
|
||||
if (ImGui::Button(radioOn ? "Radio ON" : "Radio", ImVec2(-1.f, kRowH - 2.f)))
|
||||
pushCmd(UICmd::ToggleRadio);
|
||||
ImGui::PopStyleColor();
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip(radioOn
|
||||
? "Radio control is on\nLeft-click throws switches\nWP places a Waypoint Queue stop"
|
||||
: "Turn on radio control for remote switching");
|
||||
|
||||
int editIdx = win->imRadioEditIndex.load();
|
||||
static int s_radioFocus = -1;
|
||||
if (editIdx < 0) s_radioFocus = -1;
|
||||
|
||||
if (ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows) &&
|
||||
ImGui::IsKeyPressed(ImGuiKey_Escape) && editIdx >= 0) {
|
||||
win->imRadioEditIndex.store(-1);
|
||||
editIdx = -1;
|
||||
}
|
||||
|
||||
const float nameW = ImGui::GetContentRegionAvail().x - (kBtnW + 3.f) * 2.f - 14.f;
|
||||
uint64_t aeBits = win->imRadioAeBits.load();
|
||||
int tool = win->imRadioTool.load();
|
||||
|
||||
for (int i = 0; i < n; ++i) {
|
||||
ImGui::PushID(i + 100);
|
||||
bool editing = (editIdx == i);
|
||||
bool isSel = (selected == i);
|
||||
|
||||
ImVec2 dotPos = ImGui::GetCursorScreenPos();
|
||||
dotPos.x += 2.f; dotPos.y += 7.f;
|
||||
uint32_t packed = (i < (int)colors.size()) ? colors[i] : 0;
|
||||
ImU32 dotCol = (aeBits & (1ull << i))
|
||||
? IM_COL32((packed >> 16) & 255, (packed >> 8) & 255, packed & 255, (int)(mapAlpha * 255))
|
||||
: col32(theme.txtR, theme.txtG, theme.txtB, 0.25f);
|
||||
ImGui::GetWindowDrawList()->AddCircleFilled(dotPos, 4.f, dotCol);
|
||||
ImGui::Dummy(ImVec2(12.f, 1.f));
|
||||
ImGui::SameLine();
|
||||
|
||||
if (editing) {
|
||||
if (s_radioFocus != i) {
|
||||
ImGui::SetKeyboardFocusHere();
|
||||
s_radioFocus = i;
|
||||
}
|
||||
ImGui::SetNextItemWidth(nameW);
|
||||
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(theme.popR, theme.popG, theme.popB, theme.popA));
|
||||
if (ImGui::InputText("##rn", win->imRadioRenameBuf, sizeof(win->imRadioRenameBuf),
|
||||
ImGuiInputTextFlags_EnterReturnsTrue |
|
||||
ImGuiInputTextFlags_AutoSelectAll))
|
||||
pushCmd(UICmd::RadioRename, (float)i);
|
||||
if (ImGui::IsItemDeactivatedAfterEdit())
|
||||
pushCmd(UICmd::RadioRename, (float)i);
|
||||
ImGui::PopStyleColor();
|
||||
} else {
|
||||
if (isSel) ImGui::PushStyleColor(ImGuiCol_Button, accOn);
|
||||
if (ImGui::Button(pins[i].label, ImVec2(nameW, kRowH - 4.f)))
|
||||
pushCmd(UICmd::RadioSelect, (float)i);
|
||||
if (isSel) ImGui::PopStyleColor();
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("Select and jump to this locomotive");
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
if (editing) {
|
||||
if (ImGui::Button("\xe2\x9c\x93##ok", ImVec2(kBtnW, kRowH - 4.f))) {
|
||||
pushCmd(UICmd::RadioRename, (float)i);
|
||||
win->imRadioEditIndex.store(-1);
|
||||
}
|
||||
} else if (ImGui::Button("\xe2\x9c\x8f##ed", ImVec2(kBtnW, kRowH - 4.f))) {
|
||||
strncpy_s(win->imRadioRenameBuf, pins[i].label, _TRUNCATE);
|
||||
win->imRadioEditIndex.store(i);
|
||||
}
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("Rename");
|
||||
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("x##un", ImVec2(kBtnW, kRowH - 4.f)))
|
||||
pushCmd(UICmd::RadioUnpin, (float)i);
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("Unpin");
|
||||
|
||||
if (isSel && !editing) {
|
||||
bool fwd = win->imRadioForward.load();
|
||||
float tw = (ImGui::GetContentRegionAvail().x - 4.f) * 0.5f;
|
||||
if (fwd) ImGui::PushStyleColor(ImGuiCol_Button, accOn);
|
||||
if (ImGui::Button("FWD", ImVec2(tw, kRowH - 4.f)))
|
||||
pushCmd(UICmd::RadioSetForward, 1.f);
|
||||
if (fwd) ImGui::PopStyleColor();
|
||||
ImGui::SameLine();
|
||||
if (!fwd) ImGui::PushStyleColor(ImGuiCol_Button, accOn);
|
||||
if (ImGui::Button("REV", ImVec2(tw, kRowH - 4.f)))
|
||||
pushCmd(UICmd::RadioSetForward, 0.f);
|
||||
if (!fwd) ImGui::PopStyleColor();
|
||||
|
||||
float spd = win->imRadioSpeed.load();
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 52.f);
|
||||
if (ImGui::SliderFloat("##spd", &spd, 1.f, 45.f, "%.0f"))
|
||||
win->imRadioSpeed.store(spd);
|
||||
if (ImGui::IsItemDeactivatedAfterEdit())
|
||||
pushCmd(UICmd::RadioSetSpeed, spd);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Stop", ImVec2(48.f, kRowH - 4.f)))
|
||||
pushCmd(UICmd::RadioStop);
|
||||
|
||||
float bw = (ImGui::GetContentRegionAvail().x - 4.f) * 0.5f;
|
||||
if (ImGui::Button("Fol", ImVec2(bw, kRowH - 4.f)))
|
||||
pushCmd(UICmd::RadioFollow);
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Follow this locomotive");
|
||||
ImGui::SameLine();
|
||||
bool wpOn = tool != 0;
|
||||
if (wpOn) ImGui::PushStyleColor(ImGuiCol_Button, accOn);
|
||||
if (ImGui::Button("WP", ImVec2(bw, kRowH - 4.f)))
|
||||
pushCmd(UICmd::RadioSetTool, wpOn ? 0.f : 1.f);
|
||||
if (wpOn) ImGui::PopStyleColor();
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip(wpOn
|
||||
? "Waypoint mode on \xe2\x80\x94 click track or a free coupler"
|
||||
: "Place a Waypoint Queue stop (hover snaps to track / free ends)");
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
if (ImGui::Button("+##addradio", ImVec2(-1.f, kRowH - 4.f)))
|
||||
pushCmd(UICmd::RadioPin);
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("Pin the currently selected locomotive");
|
||||
|
||||
bool hovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows);
|
||||
ImGui::EndChild();
|
||||
if (embedded)
|
||||
ImGui::EndChild();
|
||||
else
|
||||
ImGui::End();
|
||||
ImGui::PopStyleVar(3);
|
||||
ImGui::PopStyleColor(4);
|
||||
(void)col32; (void)w;
|
||||
return hovered;
|
||||
}
|
||||
|
||||
static ImVec2 RadioUvToScreen(ImVec2 imgPos, ImVec2 imgSize, float u, float v) {
|
||||
return { imgPos.x + u * imgSize.x, imgPos.y + (1.f - v) * imgSize.y };
|
||||
}
|
||||
|
||||
static void DrawRadioGhost(PopoutWindow* win, ImDrawList* dl, ImVec2 imgPos, ImVec2 imgSize) {
|
||||
if (!win || !dl || !win->imRadioGhostOn.load()) return;
|
||||
float u = win->imRadioGhostU.load();
|
||||
float v = win->imRadioGhostV.load();
|
||||
if (u < -0.05f || u > 1.05f || v < -0.05f || v > 1.05f) return;
|
||||
ImVec2 c = RadioUvToScreen(imgPos, imgSize, u, v);
|
||||
uint32_t packed = win->imRadioGhostColor.load();
|
||||
ImU32 col = IM_COL32((packed >> 16) & 255, (packed >> 8) & 255, packed & 255, 255);
|
||||
ImU32 outline = IM_COL32(0, 0, 0, 220);
|
||||
float rad = win->imRadioGhostAngle.load() * 3.14159265f / 180.f;
|
||||
ImVec2 dir(cosf(rad), sinf(rad));
|
||||
ImVec2 n(-dir.y, dir.x);
|
||||
const float len = 22.f;
|
||||
const float half = 10.f;
|
||||
ImVec2 tip = ImVec2(c.x + dir.x * len, c.y + dir.y * len);
|
||||
ImVec2 left = ImVec2(c.x - dir.x * 4.f + n.x * half, c.y - dir.y * 4.f + n.y * half);
|
||||
ImVec2 right = ImVec2(c.x - dir.x * 4.f - n.x * half, c.y - dir.y * 4.f - n.y * half);
|
||||
dl->AddTriangleFilled(tip, left, right, col);
|
||||
dl->AddTriangle(tip, left, right, outline, 1.5f);
|
||||
dl->AddCircleFilled(c, 3.f, col);
|
||||
dl->AddCircle(c, 3.f, outline, 0, 1.25f);
|
||||
}
|
||||
|
||||
static void DrawRadioWpPopup(PopoutWindow* win, ImVec2 imgPos, ImVec2 imgSize) {
|
||||
if (!win) return;
|
||||
int stage = win->imRadioWpStage.load();
|
||||
if (stage <= 0) return;
|
||||
|
||||
auto pushCmd = [&](UICmd cmd, float idx = 0.f) {
|
||||
InputEvent ev{}; ev.type = UICommand;
|
||||
ev.x = static_cast<float>(cmd); ev.y = idx;
|
||||
win->inputQueue.push(ev);
|
||||
};
|
||||
|
||||
ImVec2 p = RadioUvToScreen(imgPos, imgSize, win->imRadioWpU.load(), win->imRadioWpV.load());
|
||||
p.x += 14.f; p.y += 14.f;
|
||||
const float kW = 148.f;
|
||||
const float kH = (stage == 2) ? 92.f : 168.f;
|
||||
p.x = std::max(imgPos.x + 4.f, std::min(p.x, imgPos.x + imgSize.x - kW - 4.f));
|
||||
p.y = std::max(imgPos.y + 4.f, std::min(p.y, imgPos.y + imgSize.y - kH - 4.f));
|
||||
|
||||
ImGui::SetNextWindowPos(p, ImGuiCond_Always);
|
||||
ImGui::SetNextWindowSize(ImVec2(kW, 0.f));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.f, 6.f));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.f, 4.f));
|
||||
ImGui::Begin("##radio_wp_popup", nullptr,
|
||||
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize |
|
||||
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoNav);
|
||||
|
||||
bool wq = win->imRadioWq.load();
|
||||
bool hasCar = (win->imRadioWpFlags.load() & 1) != 0;
|
||||
static int s_wpCount = 1;
|
||||
static int s_seenStage = 0;
|
||||
if (stage != 2) s_seenStage = 0;
|
||||
|
||||
if (stage == 1) {
|
||||
ImGui::TextUnformatted("Waypoint");
|
||||
if (ImGui::Button("Go", ImVec2(-1.f, 0.f)))
|
||||
pushCmd(UICmd::RadioWpChoose, 0.f);
|
||||
if (!hasCar) ImGui::BeginDisabled();
|
||||
if (ImGui::Button("Couple", ImVec2(-1.f, 0.f)))
|
||||
pushCmd(UICmd::RadioWpChoose, 1.f);
|
||||
if (!hasCar) ImGui::EndDisabled();
|
||||
if (!wq || !hasCar) ImGui::BeginDisabled();
|
||||
if (ImGui::Button("Pickup", ImVec2(-1.f, 0.f)))
|
||||
pushCmd(UICmd::RadioWpChoose, 2.f);
|
||||
if (!wq || !hasCar) ImGui::EndDisabled();
|
||||
if (!wq) ImGui::BeginDisabled();
|
||||
if (ImGui::Button("Drop off", ImVec2(-1.f, 0.f)))
|
||||
pushCmd(UICmd::RadioWpChoose, 3.f);
|
||||
if (ImGui::Button("Cut", ImVec2(-1.f, 0.f)))
|
||||
pushCmd(UICmd::RadioWpChoose, 4.f);
|
||||
if (!wq) ImGui::EndDisabled();
|
||||
if (ImGui::Button("Cancel", ImVec2(-1.f, 0.f)))
|
||||
pushCmd(UICmd::RadioWpCancel);
|
||||
if (!wq && ImGui::IsWindowHovered())
|
||||
ImGui::SetTooltip("Pickup / Drop off / Cut need Waypoint Queue");
|
||||
} else {
|
||||
ImGui::TextUnformatted("Cars");
|
||||
if (s_seenStage != 2) {
|
||||
s_wpCount = win->imRadioWpCount.load();
|
||||
if (s_wpCount < 1) s_wpCount = 1;
|
||||
s_seenStage = 2;
|
||||
}
|
||||
ImGui::SetNextItemWidth(-1.f);
|
||||
if (ImGui::InputInt("##wpc", &s_wpCount)) {
|
||||
if (s_wpCount < 1) s_wpCount = 1;
|
||||
if (s_wpCount > 200) s_wpCount = 200;
|
||||
}
|
||||
ImGui::TextUnformatted("or click the far car");
|
||||
if (ImGui::Button("OK", ImVec2(-1.f, 0.f)))
|
||||
pushCmd(UICmd::RadioWpCount, (float)s_wpCount);
|
||||
if (ImGui::Button("Cancel", ImVec2(-1.f, 0.f)))
|
||||
pushCmd(UICmd::RadioWpCancel);
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
ImGui::PopStyleVar(2);
|
||||
}
|
||||
|
||||
static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
|
||||
bool reserveResizeGrip, bool showPopOutBtn,
|
||||
const MapThemeData& theme, float mapAlpha = 1.0f) {
|
||||
|
|
@ -775,6 +1332,9 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
|
|||
ImGui::PopStyleVar(2);
|
||||
}
|
||||
|
||||
// ── View-preset rail (top-left) ──────────────────────────────────────
|
||||
DrawPresetRail(win, origin, w, h, kBarH, theme, mapAlpha);
|
||||
|
||||
// ── Toolbar strip ────────────────────────────────────────────────────
|
||||
// Leave the bottom-right corner clear for the ImGui resize grip when in-game.
|
||||
const float kGripReserve = reserveResizeGrip ? 18.f : 0.f;
|
||||
|
|
@ -927,6 +1487,22 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
|
|||
ImGui::EndDisabled(); // !eotdOn
|
||||
ImGui::EndDisabled(); // !cullOn
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
bool wpOn = win->imWaypointsEnabled.load();
|
||||
if (ImGui::MenuItem("Show waypoints", nullptr, wpOn)) {
|
||||
win->imWaypointsEnabled.store(!wpOn);
|
||||
pushCmd(UICmd::ToggleWaypoints);
|
||||
}
|
||||
|
||||
ImGui::BeginDisabled(!win->imWaypointsEnabled.load());
|
||||
bool wpSel = win->imWaypointsSelectedOnly.load();
|
||||
if (ImGui::MenuItem("Selected locomotive only", nullptr, wpSel)) {
|
||||
win->imWaypointsSelectedOnly.store(!wpSel);
|
||||
pushCmd(UICmd::ToggleWaypointsSelectedOnly);
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
|
|
@ -1387,9 +1963,10 @@ void Renderer_Present(PopoutWindow* win) {
|
|||
g_context->Draw(3, 0);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ImGui overlay — compass rose on map + toolbar strip at bottom
|
||||
// ImGui overlay — compass rose on map + toolbar strip at bottom.
|
||||
// Plain-content windows (Car Cards) skip this so radio/presets do not appear.
|
||||
// -----------------------------------------------------------------------
|
||||
if (g_imguiInited) {
|
||||
if (g_imguiInited && !win->imPlainContent.load()) {
|
||||
MapThemeData theme = SnapshotTheme();
|
||||
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
|
|
@ -1400,6 +1977,15 @@ void Renderer_Present(PopoutWindow* win) {
|
|||
int rawWheel = win->imWheelRaw.exchange(0);
|
||||
io.MouseWheel = (float)rawWheel / WHEEL_DELTA;
|
||||
|
||||
char popChars[512];
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(win->imKeyMutex);
|
||||
strncpy_s(popChars, win->imCharsUtf8, _TRUNCATE);
|
||||
win->imCharsUtf8[0] = 0;
|
||||
}
|
||||
FeedImGuiKeys(io, popChars, win->imKeyDown.load(), win->imKeyMods.load(),
|
||||
win->imPrevKeyDown, win->imPrevKeyMods);
|
||||
|
||||
ImGui_ImplDX11_NewFrame();
|
||||
ImGui::NewFrame();
|
||||
|
||||
|
|
@ -1415,6 +2001,8 @@ void Renderer_Present(PopoutWindow* win) {
|
|||
ImGui::Render();
|
||||
ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());
|
||||
win->imWantMouse.store(ImGui::GetIO().WantCaptureMouse);
|
||||
} else {
|
||||
win->imWantMouse.store(false);
|
||||
}
|
||||
|
||||
win->swapChain->Present(0, 0);
|
||||
|
|
@ -1439,6 +2027,13 @@ static std::atomic<float> g_ovMouseX {-1.f}, g_ovMouseY {-1.f};
|
|||
static std::atomic<bool> g_ovLButton {false}, g_ovRButton {false};
|
||||
static std::atomic<int> g_ovWheelRaw {0};
|
||||
static std::atomic<bool> g_ovWantMouse {false};
|
||||
static std::atomic<bool> g_ovWantKeyboard {false};
|
||||
static std::mutex g_ovCharMutex;
|
||||
static char g_ovChars[512] {};
|
||||
static std::atomic<uint32_t> g_ovKeyDown {0};
|
||||
static std::atomic<uint32_t> g_ovKeyMods {0};
|
||||
static uint32_t g_ovPrevKeyDown = 0;
|
||||
static uint32_t g_ovPrevKeyMods = 0;
|
||||
static std::atomic<bool> g_ovVisible {false};
|
||||
|
||||
// Map texture to show in the in-game window (set from C# each frame) + UV rect.
|
||||
|
|
@ -1474,6 +2069,7 @@ void Overlay_GetMouseMapPos(float* outX, float* outY) {
|
|||
void Overlay_SetAlpha(float alpha) { g_ovAlpha.store(std::max(0.1f, std::min(1.0f, alpha))); }
|
||||
void Overlay_SetMapAlpha(float alpha) { g_mapAlpha.store(std::max(0.0f, std::min(1.0f, alpha))); }
|
||||
void Overlay_SetMapBgAlpha(float alpha) { g_mapBgAlpha.store(std::max(0.0f, std::min(1.0f, alpha))); }
|
||||
void Overlay_SetPieBlock(bool blocked) { g_ovPieBlock.store(blocked); }
|
||||
|
||||
// Map-image interaction (drag/zoom) queued here for C# to forward to the map
|
||||
// camera. Same InputEvent contract the popout uses, so C# can share the logic.
|
||||
|
|
@ -1483,6 +2079,16 @@ int Overlay_PollInput(InputEvent* out, int maxEvents) { return g_ovInput.drain(o
|
|||
void Overlay_SetDeviceTexture(void* texturePtr) { g_ovDeviceTex.store(texturePtr); }
|
||||
void Overlay_SetVisible(bool visible) { g_ovVisible.store(visible); }
|
||||
bool Overlay_WantsMouse() { return g_ovWantMouse.load(); }
|
||||
bool Overlay_WantsKeyboard() { return g_ovWantKeyboard.load(); }
|
||||
|
||||
void Overlay_SetKeyboard(const wchar_t* chars, uint32_t keyDown, uint32_t mods) {
|
||||
g_ovKeyDown.store(keyDown);
|
||||
g_ovKeyMods.store(mods);
|
||||
std::lock_guard<std::mutex> lk(g_ovCharMutex);
|
||||
g_ovChars[0] = 0;
|
||||
if (chars && chars[0])
|
||||
WideCharToMultiByte(CP_UTF8, 0, chars, -1, g_ovChars, (int)sizeof(g_ovChars), nullptr, nullptr);
|
||||
}
|
||||
|
||||
void Overlay_SetMapTexture(void* texturePtr, float u0, float v0, float u1, float v1) {
|
||||
g_ovMapTex.store(texturePtr);
|
||||
|
|
@ -1529,10 +2135,28 @@ void Renderer_PresentOverlay() {
|
|||
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
io.DisplaySize = ImVec2(w, h);
|
||||
io.MousePos = ImVec2(g_ovMouseX.load(), g_ovMouseY.load());
|
||||
io.MouseDown[0] = g_ovLButton.load();
|
||||
io.MouseDown[1] = g_ovRButton.load();
|
||||
io.MouseWheel = (float)g_ovWheelRaw.exchange(0) / WHEEL_DELTA;
|
||||
const bool pieBlock = g_ovPieBlock.load();
|
||||
if (pieBlock) {
|
||||
io.MousePos = ImVec2(-1e8f, -1e8f);
|
||||
io.MouseDown[0] = false;
|
||||
io.MouseDown[1] = false;
|
||||
io.MouseWheel = 0.f;
|
||||
g_ovWheelRaw.store(0);
|
||||
} else {
|
||||
io.MousePos = ImVec2(g_ovMouseX.load(), g_ovMouseY.load());
|
||||
io.MouseDown[0] = g_ovLButton.load();
|
||||
io.MouseDown[1] = g_ovRButton.load();
|
||||
io.MouseWheel = (float)g_ovWheelRaw.exchange(0) / WHEEL_DELTA;
|
||||
}
|
||||
|
||||
char ovChars[512];
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(g_ovCharMutex);
|
||||
strncpy_s(ovChars, g_ovChars, _TRUNCATE);
|
||||
g_ovChars[0] = 0;
|
||||
}
|
||||
FeedImGuiKeys(io, ovChars, g_ovKeyDown.load(), g_ovKeyMods.load(),
|
||||
g_ovPrevKeyDown, g_ovPrevKeyMods);
|
||||
|
||||
ImGui_ImplDX11_NewFrame();
|
||||
ImGui::NewFrame();
|
||||
|
|
@ -1540,13 +2164,15 @@ void Renderer_PresentOverlay() {
|
|||
// Chrome (window bg, title bar, toolbar, compass) alpha. Pushed before Begin()
|
||||
// so all decorations are affected; popped around the map Image() so the map
|
||||
// image has its own independent alpha, then popped finally after BuildMapUI.
|
||||
const float ovAlpha = g_ovAlpha.load();
|
||||
const float mapAlpha = g_mapAlpha.load();
|
||||
const float pieMul = pieBlock ? kPieDim : 1.f;
|
||||
const float ovAlpha = g_ovAlpha.load() * pieMul;
|
||||
const float mapAlpha = g_mapAlpha.load() * pieMul;
|
||||
const float mapBgA = g_mapBgAlpha.load();
|
||||
// While the mouse is over the overlay, clamp chrome alpha to 50% so the UI
|
||||
// remains readable even when the user has set a very low window opacity.
|
||||
// Uses last frame's WantCaptureMouse (one-frame lag; imperceptible in practice).
|
||||
const bool mouseOver = g_ovWantMouse.load();
|
||||
// Skip the floor while the pie is open — we want the map see-through.
|
||||
const bool mouseOver = !pieBlock && g_ovWantMouse.load();
|
||||
const float effectiveAlpha = mouseOver ? std::max(ovAlpha, 0.5f) : ovAlpha;
|
||||
const bool hasAlpha = effectiveAlpha < 0.999f;
|
||||
if (hasAlpha)
|
||||
|
|
@ -1612,21 +2238,18 @@ void Renderer_PresentOverlay() {
|
|||
ImVec2 imgPos = ImGui::GetCursorScreenPos();
|
||||
g_ovMapImgX.store(imgPos.x);
|
||||
g_ovMapImgY.store(imgPos.y);
|
||||
ImGui::SetNextItemAllowOverlap();
|
||||
ImGui::InvisibleButton("##mapHit", sz, ImGuiButtonFlags_MouseButtonLeft);
|
||||
bool hov = ImGui::IsItemHovered();
|
||||
bool mapActivated = ImGui::IsItemActivated();
|
||||
bool mapActive = ImGui::IsItemActive();
|
||||
bool mapDeactivated = ImGui::IsItemDeactivated();
|
||||
bool mapHov = ImGui::IsItemHovered();
|
||||
ImVec2 nrm = { (io.MousePos.x - imgPos.x) / sz.x,
|
||||
(io.MousePos.y - imgPos.y) / sz.y };
|
||||
auto pushOv = [&](int type, float d) {
|
||||
InputEvent e{}; e.type = type; e.x = nrm.x; e.y = nrm.y; e.delta = d;
|
||||
g_ovInput.push(e);
|
||||
};
|
||||
if (ImGui::IsItemActivated()) pushOv(LButtonDown, 0.f);
|
||||
if (ImGui::IsItemActive() &&
|
||||
(io.MouseDelta.x != 0.f || io.MouseDelta.y != 0.f))
|
||||
pushOv(MouseMove, 0.f);
|
||||
if (ImGui::IsItemDeactivated()) pushOv(LButtonUp, 0.f);
|
||||
if (hov && io.MouseWheel != 0.f) pushOv(MouseWheel, io.MouseWheel);
|
||||
if (hov && ImGui::IsMouseReleased(ImGuiMouseButton_Right)) pushOv(RButtonUp, 0.f);
|
||||
|
||||
// Draw the map over the same rect the hit-button occupies.
|
||||
// Map alpha is independent of chrome alpha: pop ovAlpha so
|
||||
|
|
@ -1636,6 +2259,7 @@ void Renderer_PresentOverlay() {
|
|||
ImVec2 uv1(g_ovMapU1.load(), g_ovMapV1.load());
|
||||
ImVec4 tint(theme.mapR, theme.mapG, theme.mapB, theme.mapA * mapAlpha);
|
||||
if (hasAlpha) ImGui::PopStyleVar(); // lift chrome alpha
|
||||
ImGui::SetNextItemAllowOverlap();
|
||||
ImGui::Image((ImTextureID)g_ovMapSRV.Get(), sz, uv0, uv1, tint);
|
||||
if (hasAlpha) ImGui::PushStyleVar(ImGuiStyleVar_Alpha, effectiveAlpha);
|
||||
|
||||
|
|
@ -1643,6 +2267,16 @@ void Renderer_PresentOverlay() {
|
|||
if (stateWin)
|
||||
DrawTrackLabels(stateWin, ImGui::GetWindowDrawList(), imgPos, sz);
|
||||
|
||||
// Queue map pan/click from the map image.
|
||||
if (mapActivated) pushOv(LButtonDown, 0.f);
|
||||
if (mapActive &&
|
||||
(io.MouseDelta.x != 0.f || io.MouseDelta.y != 0.f))
|
||||
pushOv(MouseMove, 0.f);
|
||||
if (mapDeactivated) pushOv(LButtonUp, 0.f);
|
||||
if (mapHov && io.MouseWheel != 0.f) pushOv(MouseWheel, io.MouseWheel);
|
||||
if (mapHov && ImGui::IsMouseReleased(ImGuiMouseButton_Right))
|
||||
pushOv(RButtonUp, 0.f);
|
||||
|
||||
// Visible resize-grip indicator. ImGui draws its own grip during
|
||||
// Begin() — behind our opaque map image, so invisible. We draw one
|
||||
// on top in the corner only while the window is focused (the dark
|
||||
|
|
@ -1713,7 +2347,8 @@ void Renderer_PresentOverlay() {
|
|||
}
|
||||
|
||||
ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());
|
||||
g_ovWantMouse.store(io.WantCaptureMouse);
|
||||
g_ovWantMouse.store(!pieBlock && io.WantCaptureMouse);
|
||||
g_ovWantKeyboard.store(io.WantTextInput);
|
||||
|
||||
// Restore Unity's render state
|
||||
saved.Restore(g_context);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#pragma once
|
||||
#include <cstdint>
|
||||
#include "popout_window.h"
|
||||
|
||||
// Called from exports.cpp (render thread only)
|
||||
|
|
@ -31,6 +32,13 @@ void Overlay_SetInput(float displayW, float displayH,
|
|||
float mouseX, float mouseY,
|
||||
bool lButton, bool rButton, int wheel);
|
||||
|
||||
// Overlay keyboard (C# pumps Unity Input System). chars is UTF-16.
|
||||
// keyDown bits: 0 Backspace .. 12 X. mods: 1 Ctrl, 2 Shift, 4 Alt.
|
||||
void Overlay_SetKeyboard(const wchar_t* chars, uint32_t keyDown, uint32_t mods);
|
||||
|
||||
// True when an ImGui text field has focus.
|
||||
bool Overlay_WantsKeyboard();
|
||||
|
||||
// The Unity map render texture to display inside the in-game ImGui window, plus
|
||||
// the UV sub-rect (V flipped: v0=1,v1=0 for a Unity RT). Pass nullptr to clear.
|
||||
void Overlay_SetMapTexture(void* texturePtr, float u0, float v0, float u1, float v1);
|
||||
|
|
@ -67,5 +75,9 @@ void Overlay_SetMapAlpha(float alpha);
|
|||
// Set the map camera clear-colour opacity [0.0, 1.0]. Thread-safe via atomic.
|
||||
void Overlay_SetMapBgAlpha(float alpha);
|
||||
|
||||
// While true, overlay stays drawn at a fraction of current alpha and ignores mouse
|
||||
// so a map-opened pie can show through and receive clicks.
|
||||
void Overlay_SetPieBlock(bool blocked);
|
||||
|
||||
// Render the overlay into the currently bound RTV. Render thread only.
|
||||
void Renderer_PresentOverlay();
|
||||
|
|
|
|||
|
|
@ -61,6 +61,13 @@ int RRPOPOUT_CreateWindow(const wchar_t* title, int width, int height) {
|
|||
return CreatePopoutWindow(title, width, height);
|
||||
}
|
||||
|
||||
extern "C" __declspec(dllexport)
|
||||
void RRPOPOUT_SetPlainContent(int windowHandle, bool plain) {
|
||||
PopoutWindow* win = GetPopoutWindow(windowHandle);
|
||||
if (!win) return;
|
||||
win->imPlainContent.store(plain);
|
||||
}
|
||||
|
||||
extern "C" __declspec(dllexport)
|
||||
void RRPOPOUT_SetFrameTexture(int windowHandle, void* texturePtr,
|
||||
float u0, float v0, float u1, float v1) {
|
||||
|
|
@ -118,6 +125,14 @@ void RRPOPOUT_SetOverlayMapTexture(void* texturePtr,
|
|||
extern "C" __declspec(dllexport)
|
||||
int RRPOPOUT_OverlayWantsMouse() { return Overlay_WantsMouse() ? 1 : 0; }
|
||||
|
||||
extern "C" __declspec(dllexport)
|
||||
int RRPOPOUT_OverlayWantsKeyboard() { return Overlay_WantsKeyboard() ? 1 : 0; }
|
||||
|
||||
extern "C" __declspec(dllexport)
|
||||
void RRPOPOUT_SetOverlayKeyboard(const wchar_t* chars, uint32_t keyDown, uint32_t mods) {
|
||||
Overlay_SetKeyboard(chars, keyDown, mods);
|
||||
}
|
||||
|
||||
// Drain queued in-game map input (drag/zoom over the map image) for C# to apply.
|
||||
extern "C" __declspec(dllexport)
|
||||
int RRPOPOUT_PollOverlayInput(InputEvent* outEvents, int maxEvents) {
|
||||
|
|
@ -356,6 +371,11 @@ void RRPOPOUT_SetOverlayAlpha(float alpha) {
|
|||
Overlay_SetAlpha(alpha);
|
||||
}
|
||||
|
||||
extern "C" __declspec(dllexport)
|
||||
void RRPOPOUT_SetOverlayPieBlock(bool blocked) {
|
||||
Overlay_SetPieBlock(blocked);
|
||||
}
|
||||
|
||||
// Set the map image alpha [0.0, 1.0]. Independent of chrome alpha.
|
||||
extern "C" __declspec(dllexport)
|
||||
void RRPOPOUT_SetOverlayMapAlpha(float alpha) {
|
||||
|
|
@ -450,6 +470,97 @@ void RRPOPOUT_SetTrackLabelStyle(int windowHandle,
|
|||
win->imTrackLabelFontSizeMin.store(fontSizeMin);
|
||||
}
|
||||
|
||||
extern "C" __declspec(dllexport)
|
||||
void RRPOPOUT_SetPresetList(int windowHandle, const wchar_t* names) {
|
||||
PopoutWindow* win = GetPopoutWindow(windowHandle);
|
||||
if (win) SetNamedList(windowHandle, names, win->imPresetList, win->imPresetMutex);
|
||||
if (win) {
|
||||
int n = 0;
|
||||
{ std::lock_guard<std::mutex> lk(win->imPresetMutex); n = (int)win->imPresetList.size(); }
|
||||
int edit = win->imPresetEditIndex.load();
|
||||
if (edit >= n) {
|
||||
win->imPresetEditIndex.store(-1);
|
||||
win->imPresetPreviewing.store(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" __declspec(dllexport)
|
||||
void RRPOPOUT_GetPresetRenameName(int windowHandle, wchar_t* outBuf, int maxChars) {
|
||||
PopoutWindow* win = GetPopoutWindow(windowHandle);
|
||||
if (!win || !outBuf || maxChars <= 0) return;
|
||||
outBuf[0] = 0;
|
||||
MultiByteToWideChar(CP_UTF8, 0, win->imPresetRenameBuf, -1, outBuf, maxChars);
|
||||
}
|
||||
|
||||
extern "C" __declspec(dllexport)
|
||||
void RRPOPOUT_SetWaypointState(int windowHandle, bool enabled, bool selectedOnly) {
|
||||
PopoutWindow* win = GetPopoutWindow(windowHandle);
|
||||
if (!win) return;
|
||||
win->imWaypointsEnabled.store(enabled);
|
||||
win->imWaypointsSelectedOnly.store(selectedOnly);
|
||||
}
|
||||
|
||||
extern "C" __declspec(dllexport)
|
||||
void RRPOPOUT_SetRadioList(int windowHandle, const wchar_t* names, const uint32_t* colors, int colorCount) {
|
||||
PopoutWindow* win = GetPopoutWindow(windowHandle);
|
||||
if (!win) return;
|
||||
SetNamedList(windowHandle, names ? names : L"", win->imRadioList, win->imRadioMutex);
|
||||
std::lock_guard<std::mutex> lk(win->imRadioMutex);
|
||||
win->imRadioColors.clear();
|
||||
if (colors && colorCount > 0)
|
||||
win->imRadioColors.assign(colors, colors + colorCount);
|
||||
int n = (int)win->imRadioList.size();
|
||||
int edit = win->imRadioEditIndex.load();
|
||||
if (edit >= n) win->imRadioEditIndex.store(-1);
|
||||
}
|
||||
|
||||
extern "C" __declspec(dllexport)
|
||||
void RRPOPOUT_SetRadioState(int windowHandle, bool radioOn, int selected, int tool,
|
||||
bool wqInstalled, uint64_t aeBits, bool forward, float speed) {
|
||||
PopoutWindow* win = GetPopoutWindow(windowHandle);
|
||||
if (!win) return;
|
||||
win->imRadioOn.store(radioOn);
|
||||
win->imRadioSelected.store(selected);
|
||||
win->imRadioTool.store(tool);
|
||||
win->imRadioWq.store(wqInstalled);
|
||||
win->imRadioAeBits.store(aeBits);
|
||||
win->imRadioForward.store(forward);
|
||||
win->imRadioSpeed.store(speed);
|
||||
}
|
||||
|
||||
extern "C" __declspec(dllexport)
|
||||
void RRPOPOUT_GetRadioRenameName(int windowHandle, wchar_t* outBuf, int maxChars) {
|
||||
PopoutWindow* win = GetPopoutWindow(windowHandle);
|
||||
if (!win || !outBuf || maxChars <= 0) return;
|
||||
outBuf[0] = 0;
|
||||
MultiByteToWideChar(CP_UTF8, 0, win->imRadioRenameBuf, -1, outBuf, maxChars);
|
||||
}
|
||||
|
||||
extern "C" __declspec(dllexport)
|
||||
void RRPOPOUT_SetRadioGhost(int windowHandle, bool visible, float u, float v,
|
||||
float angleDeg, uint32_t color) {
|
||||
PopoutWindow* win = GetPopoutWindow(windowHandle);
|
||||
if (!win) return;
|
||||
win->imRadioGhostOn.store(visible);
|
||||
win->imRadioGhostU.store(u);
|
||||
win->imRadioGhostV.store(v);
|
||||
win->imRadioGhostAngle.store(angleDeg);
|
||||
win->imRadioGhostColor.store(color);
|
||||
}
|
||||
|
||||
extern "C" __declspec(dllexport)
|
||||
void RRPOPOUT_SetRadioWpPopup(int windowHandle, int stage, float u, float v,
|
||||
int flags, int count) {
|
||||
PopoutWindow* win = GetPopoutWindow(windowHandle);
|
||||
if (!win) return;
|
||||
win->imRadioWpStage.store(stage);
|
||||
win->imRadioWpU.store(u);
|
||||
win->imRadioWpV.store(v);
|
||||
win->imRadioWpFlags.store(flags);
|
||||
win->imRadioWpCount.store(count < 1 ? 1 : count);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin_Initialize / Plugin_Shutdown (called from dllmain.cpp)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
#include <Windows.h>
|
||||
#include <d3d11.h>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
#include "input_queue.h"
|
||||
|
||||
struct PopoutWindow {
|
||||
|
|
@ -50,6 +52,10 @@ struct PopoutWindow {
|
|||
// When true, PollInputEvents suppresses mouse events so they don't reach Unity.
|
||||
std::atomic<bool> imWantMouse {false};
|
||||
|
||||
// When true, Present blits the frame texture only: no map toolbar, compass,
|
||||
// radio rail, or view presets. Used by Car Cards (and any future non-map window).
|
||||
std::atomic<bool> imPlainContent {false};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Status bar label (UTF-8), shown in the ImGui toolbar.
|
||||
// Written by RRPOPOUT_SetStatusText (main thread), read by render thread.
|
||||
|
|
@ -142,6 +148,52 @@ struct PopoutWindow {
|
|||
std::atomic<bool> imTrackLabelAvoidTrack {false};
|
||||
std::atomic<float> imTrackLabelFontSizeMin {8.f};
|
||||
|
||||
// Named camera-view presets (C# owns the data; native draws the left rail).
|
||||
std::vector<ImMenuItem> imPresetList;
|
||||
std::mutex imPresetMutex;
|
||||
std::atomic<int> imPresetEditIndex {-1};
|
||||
std::atomic<bool> imPresetPreviewing {false};
|
||||
std::atomic<int> imPresetPendingDelete{-1};
|
||||
char imPresetRenameBuf[128] {};
|
||||
|
||||
// AE waypoint pins (gear-menu toggles).
|
||||
std::atomic<bool> imWaypointsEnabled {true};
|
||||
std::atomic<bool> imWaypointsSelectedOnly {false};
|
||||
|
||||
// Radio-control rail (top-right). C# owns pin ids; native draws the list.
|
||||
std::vector<ImMenuItem> imRadioList;
|
||||
std::vector<uint32_t> imRadioColors;
|
||||
std::mutex imRadioMutex;
|
||||
std::atomic<bool> imRadioOn {false};
|
||||
std::atomic<int> imRadioSelected {-1};
|
||||
std::atomic<int> imRadioTool {0};
|
||||
std::atomic<bool> imRadioWq {false};
|
||||
std::atomic<uint64_t> imRadioAeBits {0};
|
||||
std::atomic<bool> imRadioForward {true};
|
||||
std::atomic<float> imRadioSpeed {15.f};
|
||||
std::atomic<int> imRadioEditIndex {-1};
|
||||
char imRadioRenameBuf[128] {};
|
||||
|
||||
// Waypoint-mode ghost arrow (Unity viewport UV, v=0 at bottom) + near-cursor popup.
|
||||
std::atomic<bool> imRadioGhostOn {false};
|
||||
std::atomic<float> imRadioGhostU {0.f};
|
||||
std::atomic<float> imRadioGhostV {0.f};
|
||||
std::atomic<float> imRadioGhostAngle {0.f}; // screen deg, 0=right, +CW, y-down
|
||||
std::atomic<uint32_t> imRadioGhostColor {0xFFFFFFFFu};
|
||||
std::atomic<int> imRadioWpStage {0}; // 0 off, 1 choose order, 2 enter count
|
||||
std::atomic<float> imRadioWpU {0.f};
|
||||
std::atomic<float> imRadioWpV {0.f};
|
||||
std::atomic<int> imRadioWpFlags {0}; // bit0 = has couple target
|
||||
std::atomic<int> imRadioWpCount {1};
|
||||
|
||||
// Keyboard for ImGui InputText (preset rename). Pump thread writes, render thread reads.
|
||||
std::mutex imKeyMutex;
|
||||
char imCharsUtf8[512] {};
|
||||
std::atomic<uint32_t> imKeyDown {0};
|
||||
std::atomic<uint32_t> imKeyMods {0};
|
||||
uint32_t imPrevKeyDown = 0;
|
||||
uint32_t imPrevKeyMods = 0;
|
||||
|
||||
// Loaded geometry from the save file — consumed by MessagePumpThread before CreateWindowExW.
|
||||
std::atomic<int> lastWinX {-1}, lastWinY {-1};
|
||||
std::atomic<int> lastWinW {900}, lastWinH {700};
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
#include <mutex>
|
||||
#include <atomic>
|
||||
#include <cstdio> // FILE*, fopen_s, fprintf, fgets
|
||||
#include <cstring> // strcmp, strchr
|
||||
#include <cstring> // strcmp, strchr, strlen, memcpy
|
||||
#include <cstdint>
|
||||
#include <string> // std::wstring
|
||||
#include "popout_window.h"
|
||||
#include "popout_windows.h" // WM_RRPOPOUT_SET_TOPMOST constant
|
||||
|
|
@ -94,6 +95,8 @@ static LRESULT CALLBACK ContentWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARA
|
|||
case WM_LBUTTONDOWN: case WM_LBUTTONUP:
|
||||
case WM_RBUTTONDOWN: case WM_RBUTTONUP:
|
||||
case WM_MOUSEMOVE: case WM_MOUSEWHEEL:
|
||||
case WM_CHAR: case WM_KEYDOWN: case WM_KEYUP:
|
||||
case WM_SYSKEYDOWN: case WM_SYSKEYUP:
|
||||
if (HWND parent = GetParent(hwnd))
|
||||
return SendMessageW(parent, msg, wParam, lParam);
|
||||
break;
|
||||
|
|
@ -281,6 +284,33 @@ static DWORD WINAPI MessagePumpThread(LPVOID param) {
|
|||
// ---------------------------------------------------------------------------
|
||||
// WndProc
|
||||
// ---------------------------------------------------------------------------
|
||||
static uint32_t VkToKeyBit(WPARAM vk) {
|
||||
switch (vk) {
|
||||
case VK_BACK: return 1u << 0;
|
||||
case VK_DELETE: return 1u << 1;
|
||||
case VK_RETURN: return 1u << 2;
|
||||
case VK_ESCAPE: return 1u << 3;
|
||||
case VK_LEFT: return 1u << 4;
|
||||
case VK_RIGHT: return 1u << 5;
|
||||
case VK_HOME: return 1u << 6;
|
||||
case VK_END: return 1u << 7;
|
||||
case VK_TAB: return 1u << 8;
|
||||
case 'A': case 'a': return 1u << 9;
|
||||
case 'C': case 'c': return 1u << 10;
|
||||
case 'V': case 'v': return 1u << 11;
|
||||
case 'X': case 'x': return 1u << 12;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static void UpdateKeyMods(PopoutWindow* win) {
|
||||
uint32_t m = 0;
|
||||
if (GetKeyState(VK_CONTROL) & 0x8000) m |= 1u;
|
||||
if (GetKeyState(VK_SHIFT) & 0x8000) m |= 2u;
|
||||
if (GetKeyState(VK_MENU) & 0x8000) m |= 4u;
|
||||
win->imKeyMods.store(m);
|
||||
}
|
||||
|
||||
static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
||||
PopoutWindow* win = reinterpret_cast<PopoutWindow*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
|
||||
|
||||
|
|
@ -366,6 +396,37 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lPara
|
|||
return 0;
|
||||
}
|
||||
|
||||
case WM_CHAR: {
|
||||
if (!win || wParam < 32) return 0;
|
||||
wchar_t wc = (wchar_t)wParam;
|
||||
char utf8[8] = {};
|
||||
int n = WideCharToMultiByte(CP_UTF8, 0, &wc, 1, utf8, (int)sizeof(utf8) - 1, nullptr, nullptr);
|
||||
if (n <= 0) return 0;
|
||||
std::lock_guard<std::mutex> lk(win->imKeyMutex);
|
||||
size_t have = strlen(win->imCharsUtf8);
|
||||
if (have + (size_t)n < sizeof(win->imCharsUtf8) - 1) {
|
||||
memcpy(win->imCharsUtf8 + have, utf8, (size_t)n);
|
||||
win->imCharsUtf8[have + (size_t)n] = 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
case WM_KEYDOWN: case WM_SYSKEYDOWN: {
|
||||
if (!win) break;
|
||||
UpdateKeyMods(win);
|
||||
uint32_t bit = VkToKeyBit(wParam);
|
||||
if (bit) win->imKeyDown.fetch_or(bit);
|
||||
return 0;
|
||||
}
|
||||
|
||||
case WM_KEYUP: case WM_SYSKEYUP: {
|
||||
if (!win) break;
|
||||
UpdateKeyMods(win);
|
||||
uint32_t bit = VkToKeyBit(wParam);
|
||||
if (bit) win->imKeyDown.fetch_and(~bit);
|
||||
return 0;
|
||||
}
|
||||
|
||||
case WM_ENTERSIZEMOVE:
|
||||
if (win) win->resizing.store(true);
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
{
|
||||
"Releases": [
|
||||
{
|
||||
"Id": "S3",
|
||||
"Version": "0.3b",
|
||||
"DownloadUrl": "https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/releases/download/0.3b/SetonsSpecialSauce-0.3b.zip"
|
||||
},
|
||||
{
|
||||
"Id": "S3",
|
||||
"Version": "0.2.7",
|
||||
|
|
@ -9,11 +14,6 @@
|
|||
"Id": "S3",
|
||||
"Version": "0.2.5",
|
||||
"DownloadUrl": "https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/releases/download/0.2.5/SetonsSpecialSauce-0.2.5.zip"
|
||||
},
|
||||
{
|
||||
"Id": "S3",
|
||||
"Version": "0.2.4",
|
||||
"DownloadUrl": "https://git.farmtowntech.com/setonc/railroader-setons-special-sauce/releases/download/0.2.4/SetonsSpecialSauce-0.2.4.zip"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using UnityModManagerNet;
|
||||
|
||||
namespace S3.Core;
|
||||
|
|
@ -8,11 +9,56 @@ namespace S3.Core;
|
|||
/// </summary>
|
||||
public static class Log
|
||||
{
|
||||
private const int RingN = 200;
|
||||
private static readonly object RingLock = new();
|
||||
private static readonly string[] Ring = new string[RingN];
|
||||
private static int _ringCount;
|
||||
|
||||
private static UnityModManager.ModEntry.ModLogger? _logger;
|
||||
|
||||
public static void Init(UnityModManager.ModEntry modEntry) => _logger = modEntry.Logger;
|
||||
|
||||
public static void Info(string msg) => _logger?.Log(msg);
|
||||
public static void Warn(string msg) => _logger?.Warning(msg);
|
||||
public static void Error(string msg) => _logger?.Error(msg);
|
||||
public static void Info(string msg)
|
||||
{
|
||||
Push("INF", msg);
|
||||
_logger?.Log(msg);
|
||||
}
|
||||
|
||||
public static void Warn(string msg)
|
||||
{
|
||||
Push("WRN", msg);
|
||||
_logger?.Warning(msg);
|
||||
}
|
||||
|
||||
public static void Error(string msg)
|
||||
{
|
||||
Push("ERR", msg);
|
||||
_logger?.Error(msg);
|
||||
}
|
||||
|
||||
public static string[] Tail(int count)
|
||||
{
|
||||
if (count < 1) count = 1;
|
||||
if (count > RingN) count = RingN;
|
||||
lock (RingLock)
|
||||
{
|
||||
int have = Math.Min(_ringCount, RingN);
|
||||
int take = Math.Min(count, have);
|
||||
var result = new string[take];
|
||||
int start = _ringCount - take;
|
||||
for (int i = 0; i < take; i++)
|
||||
result[i] = Ring[(start + i) % RingN];
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
static void Push(string level, string msg)
|
||||
{
|
||||
string line = DateTime.Now.ToString("HH:mm:ss") + " [" + level + "] " + msg;
|
||||
lock (RingLock)
|
||||
{
|
||||
Ring[_ringCount % RingN] = line;
|
||||
_ringCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ namespace S3.Core;
|
|||
public sealed class ModuleRegistry
|
||||
{
|
||||
private readonly List<IModule> _modules = new();
|
||||
private readonly HashSet<IModule> _active = new();
|
||||
|
||||
public IReadOnlyList<IModule> Modules => _modules;
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ public sealed class ModuleRegistry
|
|||
try
|
||||
{
|
||||
m.OnEnable();
|
||||
_active.Add(m);
|
||||
Log.Info($"[{m.Id}] enabled.");
|
||||
}
|
||||
catch (Exception e)
|
||||
|
|
@ -38,6 +40,66 @@ public sealed class ModuleRegistry
|
|||
}
|
||||
}
|
||||
|
||||
public bool IsActive(IModule module) => _active.Contains(module);
|
||||
|
||||
public bool IsActive(string id)
|
||||
{
|
||||
IModule? module = Find(id);
|
||||
return module != null && _active.Contains(module);
|
||||
}
|
||||
|
||||
public IModule? Find(string id)
|
||||
{
|
||||
foreach (IModule module in _modules)
|
||||
if (string.Equals(module.Id, id, StringComparison.OrdinalIgnoreCase))
|
||||
return module;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes a module's live state without changing its persisted Enabled setting.
|
||||
/// Callers that want the change persisted must update Enabled and SaveSettings.
|
||||
/// </summary>
|
||||
public bool TrySetActive(string id, bool active, out string message)
|
||||
{
|
||||
IModule? module = Find(id);
|
||||
if (module == null)
|
||||
{
|
||||
message = "unknown module: " + id;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool current = _active.Contains(module);
|
||||
if (current == active)
|
||||
{
|
||||
message = $"{module.Id} active={active} (unchanged)";
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (active)
|
||||
{
|
||||
module.OnEnable();
|
||||
_active.Add(module);
|
||||
}
|
||||
else
|
||||
{
|
||||
module.OnDisable();
|
||||
_active.Remove(module);
|
||||
}
|
||||
message = $"{module.Id} active={active}";
|
||||
Log.Info($"[{module.Id}] live active={active}.");
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
message = $"{module.Id} active={current}; transition failed: {e.Message}";
|
||||
Log.Error($"[{module.Id}] live transition to active={active} failed: {e}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Persist every module's settings (called from UMM's OnSaveGUI).</summary>
|
||||
public void SaveAll()
|
||||
{
|
||||
|
|
|
|||
85
src/Core/Ui/StockMapGuard.cs
Normal file
85
src/Core/Ui/StockMapGuard.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using HarmonyLib;
|
||||
using S3.Modules.Popout;
|
||||
using UI.Common;
|
||||
using UI.Map;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Core.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the stock Unity MapWindow invisible while the Map Module is enabled.
|
||||
/// The overlay/popout still call MapWindow.Show (with MapBypass) so the camera
|
||||
/// and icons initialize; this collapses the game's own panel so it can never
|
||||
/// steal the view or flash over the ImGui map.
|
||||
/// </summary>
|
||||
internal static class StockMapGuard
|
||||
{
|
||||
private static Vector3 _savedScale = Vector3.one;
|
||||
private static bool _haveScale;
|
||||
|
||||
public static void Tick()
|
||||
{
|
||||
if (!PopoutModule.Settings.enabled)
|
||||
{
|
||||
RestoreIfNeeded();
|
||||
return;
|
||||
}
|
||||
CollapseCurrent();
|
||||
}
|
||||
|
||||
public static void CollapseCurrent()
|
||||
{
|
||||
var win = PanelFinder.GetMapWindowUI();
|
||||
if (win == null) return;
|
||||
Collapse(win);
|
||||
}
|
||||
|
||||
public static void Collapse(Window? win)
|
||||
{
|
||||
if (win == null) return;
|
||||
if (win.transform is not RectTransform rt) return;
|
||||
if (!_haveScale && rt.localScale != Vector3.zero)
|
||||
{
|
||||
_savedScale = rt.localScale;
|
||||
_haveScale = true;
|
||||
}
|
||||
if (rt.localScale != Vector3.zero)
|
||||
rt.localScale = Vector3.zero;
|
||||
}
|
||||
|
||||
public static void Collapse(MapWindow? mw)
|
||||
{
|
||||
if (mw == null) return;
|
||||
Collapse(Traverse.Create(mw).Field<Window>("_window").Value);
|
||||
}
|
||||
|
||||
private static void RestoreIfNeeded()
|
||||
{
|
||||
if (!_haveScale) return;
|
||||
var win = PanelFinder.GetMapWindowUI();
|
||||
if (win != null && win.transform is RectTransform rt)
|
||||
rt.localScale = _savedScale;
|
||||
_haveScale = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Any Show/Toggle that reaches the stock window (bypass, other mods, animations)
|
||||
// must stay at scale zero while the Map Module is on.
|
||||
[HarmonyPatch(typeof(MapWindow), "OnWindowShown")]
|
||||
internal static class MapWindow_OnWindowShown_Patch
|
||||
{
|
||||
private static void Postfix(MapWindow __instance, bool shown)
|
||||
{
|
||||
if (!PopoutModule.Settings.enabled) return;
|
||||
try
|
||||
{
|
||||
StockMapGuard.Collapse(__instance);
|
||||
if (shown && !UiService.MapBypass && !UiService.IsOverlayVisible && !PopoutModule.IsDetached)
|
||||
UiService.OpenOverlay();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Log.Error($"[ui] stock map collapse: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -16,6 +16,7 @@ namespace S3;
|
|||
public static class Main
|
||||
{
|
||||
internal static UnityModManager.ModEntry ModEntry { get; private set; } = null!;
|
||||
public static ModuleRegistry Registry { get; private set; } = null!;
|
||||
|
||||
private static ModuleRegistry _registry = null!;
|
||||
|
||||
|
|
@ -26,12 +27,19 @@ public static class Main
|
|||
SettingsStore.Init(modEntry.Path);
|
||||
|
||||
_registry = new ModuleRegistry();
|
||||
Registry = _registry;
|
||||
// Modules are registered here. Each module loads its own settings in its
|
||||
// constructor. Order here is display order in the settings panel.
|
||||
_registry.Register(new Modules.PhysicsOptimizer.PhysicsOptimizerModule());
|
||||
_registry.Register(new Modules.MeshLod.MeshLodModule());
|
||||
_registry.Register(new Modules.BaseGamePerf.BaseGamePerfModule());
|
||||
_registry.Register(new Modules.Profiler.ProfilerModule());
|
||||
_registry.Register(new Modules.MiscTweaks.MiscTweaksModule());
|
||||
_registry.Register(new Modules.Popout.PopoutModule());
|
||||
_registry.Register(new Modules.QuickActions.QuickActionsModule());
|
||||
_registry.Register(new Modules.CarCards.CarCardsModule());
|
||||
_registry.Register(new Modules.IndustryTags.IndustryTagsModule());
|
||||
_registry.Register(new Modules.Mcp.McpModule());
|
||||
|
||||
_registry.EnableConfigured();
|
||||
ModConflicts.CheckAtLoad();
|
||||
|
|
@ -39,6 +47,7 @@ public static class Main
|
|||
// Always-on core UI service: hosts Dear ImGui inside the game, intercepts
|
||||
// the base-game map hotkey, and enforces popout<->in-game mutual exclusion.
|
||||
UiService.Install();
|
||||
Modules.Popout.WqDumpCommand.Install();
|
||||
|
||||
modEntry.OnGUI = _ => SettingsPanel.Draw(_registry);
|
||||
modEntry.OnSaveGUI = _ => _registry.SaveAll();
|
||||
|
|
|
|||
82
src/Modules/BaseGamePerf/BaseGamePerfModule.cs
Normal file
82
src/Modules/BaseGamePerf/BaseGamePerfModule.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using S3.Core;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Scripting;
|
||||
|
||||
namespace S3.Modules.BaseGamePerf;
|
||||
|
||||
public sealed class BaseGamePerfModule : IModule
|
||||
{
|
||||
const string SettingsFile = "S3.basegame.json";
|
||||
|
||||
static bool _hasOriginal;
|
||||
static ulong _originalSliceNanoseconds;
|
||||
|
||||
public static BaseGamePerfSettings Settings { get; private set; } = new();
|
||||
|
||||
public BaseGamePerfModule() =>
|
||||
Settings = SettingsStore.Load<BaseGamePerfSettings>(SettingsFile);
|
||||
|
||||
public string Id => "basegame";
|
||||
public string DisplayName => "Base Game Performance";
|
||||
public string Description =>
|
||||
"Evidence-backed vanilla-game hitch controls. Smooths incremental GC and " +
|
||||
"Nature Renderer grass-cell streaming without reducing density, draw distance, " +
|
||||
"resolution, or simulation quality.";
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get => Settings.enabled;
|
||||
set => Settings.enabled = value;
|
||||
}
|
||||
|
||||
public void OnEnable()
|
||||
{
|
||||
if (!_hasOriginal)
|
||||
{
|
||||
_originalSliceNanoseconds = GarbageCollector.incrementalTimeSliceNanoseconds;
|
||||
_hasOriginal = true;
|
||||
}
|
||||
try { NatureStreamingOptimizer.Enable(); }
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Log.Warn(
|
||||
"[basegame] Nature streaming smoothing unavailable: " +
|
||||
ex.GetBaseException().Message);
|
||||
}
|
||||
ApplyRuntimeSettings();
|
||||
}
|
||||
|
||||
public void OnDisable()
|
||||
{
|
||||
NatureStreamingOptimizer.Disable();
|
||||
if (_hasOriginal)
|
||||
{
|
||||
GarbageCollector.incrementalTimeSliceNanoseconds = _originalSliceNanoseconds;
|
||||
Log.Info(
|
||||
$"[basegame] Restored incremental GC slice to " +
|
||||
$"{_originalSliceNanoseconds / 1_000_000.0:0.###}ms.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void ApplyRuntimeSettings()
|
||||
{
|
||||
if (!GarbageCollector.isIncremental)
|
||||
{
|
||||
Log.Warn("[basegame] Unity incremental GC is disabled; smoothing was not applied.");
|
||||
return;
|
||||
}
|
||||
|
||||
ulong desired = Settings.gcSmoothingEnabled
|
||||
? (ulong)(Mathf.Clamp(Settings.incrementalSliceMs, 0.25f, 5f) * 1_000_000f)
|
||||
: _originalSliceNanoseconds;
|
||||
GarbageCollector.incrementalTimeSliceNanoseconds = desired;
|
||||
Log.Info(
|
||||
$"[basegame] Incremental GC slice={desired / 1_000_000.0:0.###}ms " +
|
||||
$"(smoothing={Settings.gcSmoothingEnabled}).");
|
||||
NatureStreamingOptimizer.ApplySettings();
|
||||
}
|
||||
|
||||
public void SaveSettings() => Persist();
|
||||
public static void Persist() => SettingsStore.Save(SettingsFile, Settings);
|
||||
public void DrawSettings() => BaseGamePerfSettingsUI.Draw();
|
||||
}
|
||||
16
src/Modules/BaseGamePerf/BaseGamePerfSettings.cs
Normal file
16
src/Modules/BaseGamePerf/BaseGamePerfSettings.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
using System;
|
||||
|
||||
namespace S3.Modules.BaseGamePerf;
|
||||
|
||||
[Serializable]
|
||||
public sealed class BaseGamePerfSettings
|
||||
{
|
||||
public bool enabled = false;
|
||||
public bool gcSmoothingEnabled = true;
|
||||
public float incrementalSliceMs = 1f;
|
||||
public bool natureStreamingSmoothingEnabled = true;
|
||||
public int grassInstanceBudgetPerFrame = 256;
|
||||
public bool queueNearbyGrassLoads = true;
|
||||
public int grassUnloadSpreadFrames = 120;
|
||||
public bool distanceCullNatureTerrains = false;
|
||||
}
|
||||
112
src/Modules/BaseGamePerf/BaseGamePerfSettingsUI.cs
Normal file
112
src/Modules/BaseGamePerf/BaseGamePerfSettingsUI.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.BaseGamePerf;
|
||||
|
||||
static class BaseGamePerfSettingsUI
|
||||
{
|
||||
public static void Draw()
|
||||
{
|
||||
BaseGamePerfSettings s = BaseGamePerfModule.Settings;
|
||||
bool changed = false;
|
||||
|
||||
GUILayout.BeginVertical();
|
||||
GUILayout.Label("<b>Base Game Performance</b> — conservative vanilla hitch controls");
|
||||
GUILayout.Space(4f);
|
||||
GUILayout.Label(
|
||||
" Smooths Unity's incremental garbage collection. This does not alter\n" +
|
||||
" resolution, shadows, draw distance, scenery, or rolling-stock detail.",
|
||||
GUI.skin.label);
|
||||
GUILayout.Space(8f);
|
||||
|
||||
bool enabled = GUILayout.Toggle(
|
||||
s.gcSmoothingEnabled,
|
||||
" Shorter incremental GC slices");
|
||||
if (enabled != s.gcSmoothingEnabled)
|
||||
{
|
||||
s.gcSmoothingEnabled = enabled;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label($"Slice budget: {s.incrementalSliceMs:0.00}ms", GUILayout.Width(150f));
|
||||
float slice = GUILayout.HorizontalSlider(
|
||||
s.incrementalSliceMs, 0.25f, 3f, GUILayout.Width(200f));
|
||||
GUILayout.EndHorizontal();
|
||||
if (Mathf.Abs(slice - s.incrementalSliceMs) > 0.01f)
|
||||
{
|
||||
s.incrementalSliceMs = slice;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
GUILayout.Label(
|
||||
" Lower values reduce individual GC stalls but spread collection work\n" +
|
||||
" across more frames. 1.00ms is the measured starting point.",
|
||||
GUI.skin.label);
|
||||
GUILayout.Space(10f);
|
||||
|
||||
bool nature = GUILayout.Toggle(
|
||||
s.natureStreamingSmoothingEnabled,
|
||||
" Smooth Nature Renderer grass streaming");
|
||||
if (nature != s.natureStreamingSmoothingEnabled)
|
||||
{
|
||||
s.natureStreamingSmoothingEnabled = nature;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label(
|
||||
$"Grass load budget: {s.grassInstanceBudgetPerFrame} instances/terrain",
|
||||
GUILayout.Width(250f));
|
||||
int grassBudget = Mathf.RoundToInt(GUILayout.HorizontalSlider(
|
||||
s.grassInstanceBudgetPerFrame, 64f, 1024f, GUILayout.Width(200f)));
|
||||
GUILayout.EndHorizontal();
|
||||
if (grassBudget != s.grassInstanceBudgetPerFrame)
|
||||
{
|
||||
s.grassInstanceBudgetPerFrame = grassBudget;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
bool queueNearby = GUILayout.Toggle(
|
||||
s.queueNearbyGrassLoads,
|
||||
" Queue nearby grass cells instead of force-loading them");
|
||||
if (queueNearby != s.queueNearbyGrassLoads)
|
||||
{
|
||||
s.queueNearbyGrassLoads = queueNearby;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label(
|
||||
$"Grass unload spread: {s.grassUnloadSpreadFrames} frames",
|
||||
GUILayout.Width(250f));
|
||||
int unloadSpread = Mathf.RoundToInt(GUILayout.HorizontalSlider(
|
||||
s.grassUnloadSpreadFrames, 0f, 300f, GUILayout.Width(200f)));
|
||||
GUILayout.EndHorizontal();
|
||||
if (unloadSpread != s.grassUnloadSpreadFrames)
|
||||
{
|
||||
s.grassUnloadSpreadFrames = unloadSpread;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
bool distanceCull = GUILayout.Toggle(
|
||||
s.distanceCullNatureTerrains,
|
||||
" Unload Nature Renderer data for distant terrain tiles (experimental)");
|
||||
if (distanceCull != s.distanceCullNatureTerrains)
|
||||
{
|
||||
s.distanceCullNatureTerrains = distanceCull;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
GUILayout.Label(
|
||||
" The default 256/120 settings preserve density and draw distance while\n" +
|
||||
" spreading cell uploads and expiry across frames. Distant-terrain\n" +
|
||||
" lifecycle is off by default until portal-camera visuals are validated.",
|
||||
GUI.skin.label);
|
||||
GUILayout.EndVertical();
|
||||
|
||||
if (!changed) return;
|
||||
if (Main.Registry.IsActive("basegame"))
|
||||
BaseGamePerfModule.ApplyRuntimeSettings();
|
||||
BaseGamePerfModule.Persist();
|
||||
}
|
||||
}
|
||||
258
src/Modules/BaseGamePerf/NatureStreamingOptimizer.cs
Normal file
258
src/Modules/BaseGamePerf/NatureStreamingOptimizer.cs
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
using S3.Core;
|
||||
using UnityEngine;
|
||||
using VisualDesignCafe.Rendering.Instancing;
|
||||
using VisualDesignCafe.Rendering.Nature;
|
||||
|
||||
namespace S3.Modules.BaseGamePerf;
|
||||
|
||||
/// <summary>
|
||||
/// Conservative smoothing for Nature Renderer's terrain-detail streaming.
|
||||
/// It changes scheduling and residency only; density, render distance,
|
||||
/// materials, shadows, and terrain content are untouched.
|
||||
/// </summary>
|
||||
static class NatureStreamingOptimizer
|
||||
{
|
||||
sealed class RendererState
|
||||
{
|
||||
public WeakReference<NatureRenderer> Renderer = null!;
|
||||
public bool OnlyInitializeWithinRenderingDistance;
|
||||
}
|
||||
|
||||
static readonly Dictionary<int, RendererState> OriginalRenderers = new();
|
||||
static readonly FieldInfo? RenderingDistanceLimitField =
|
||||
AccessTools.Field(typeof(NatureRenderer), "_renderingDistanceLimit");
|
||||
static readonly FieldInfo? StreamingBudgetField =
|
||||
AccessTools.Field(typeof(TerrainGrassStreamer), "_globalStreamingBudget");
|
||||
static readonly FieldInfo? ForceNearbyField =
|
||||
AccessTools.Field(typeof(TerrainGrassStreamer), "_globalNearbyCellLoading");
|
||||
static readonly FieldInfo? TimersField =
|
||||
AccessTools.Field(typeof(TerrainGrassStreamer), "_inRangeOfAnyCamera");
|
||||
|
||||
static Harmony? _harmony;
|
||||
static int _originalStreamingBudget = 1024;
|
||||
static bool _originalForceNearby = true;
|
||||
|
||||
public static void Enable()
|
||||
{
|
||||
if (_harmony != null) return;
|
||||
CaptureGrassGlobals();
|
||||
_harmony = new Harmony("S3.basegame.nature");
|
||||
_harmony.CreateClassProcessor(
|
||||
typeof(GrassBudgetSetterPatch)).Patch();
|
||||
_harmony.CreateClassProcessor(
|
||||
typeof(GrassNearbySetterPatch)).Patch();
|
||||
_harmony.CreateClassProcessor(
|
||||
typeof(NatureRendererOnEnablePatch)).Patch();
|
||||
_harmony.CreateClassProcessor(
|
||||
typeof(NatureRendererRangePatch)).Patch();
|
||||
_harmony.CreateClassProcessor(
|
||||
typeof(TerrainGrassExpiryPatch)).Patch();
|
||||
ApplySettings();
|
||||
}
|
||||
|
||||
public static void Disable()
|
||||
{
|
||||
_harmony?.UnpatchAll("S3.basegame.nature");
|
||||
_harmony = null;
|
||||
RestoreRendererSettings();
|
||||
try
|
||||
{
|
||||
TerrainGrassStreamer.SetStreamingBudget(_originalStreamingBudget);
|
||||
TerrainGrassStreamer.SetForceLoadNearbyCells(_originalForceNearby);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static void ApplySettings()
|
||||
{
|
||||
BaseGamePerfSettings settings = BaseGamePerfModule.Settings;
|
||||
if (!settings.natureStreamingSmoothingEnabled)
|
||||
{
|
||||
RestoreRendererSettings();
|
||||
TerrainGrassStreamer.SetStreamingBudget(_originalStreamingBudget);
|
||||
TerrainGrassStreamer.SetForceLoadNearbyCells(_originalForceNearby);
|
||||
return;
|
||||
}
|
||||
|
||||
TerrainGrassStreamer.SetStreamingBudget(
|
||||
Mathf.Clamp(settings.grassInstanceBudgetPerFrame, 64, 4096));
|
||||
TerrainGrassStreamer.SetForceLoadNearbyCells(
|
||||
!settings.queueNearbyGrassLoads);
|
||||
|
||||
NatureRenderer[] renderers =
|
||||
Resources.FindObjectsOfTypeAll<NatureRenderer>();
|
||||
for (int i = 0; i < renderers.Length; i++)
|
||||
TrackAndApply(renderers[i]);
|
||||
|
||||
Log.Info(
|
||||
$"[basegame] Nature streaming: grassBudget=" +
|
||||
$"{settings.grassInstanceBudgetPerFrame} instances/frame, " +
|
||||
$"queueNearby={settings.queueNearbyGrassLoads}, " +
|
||||
$"unloadSpread={settings.grassUnloadSpreadFrames} frames, " +
|
||||
$"distanceLifecycle={settings.distanceCullNatureTerrains}, " +
|
||||
$"terrains={OriginalRenderers.Count}.");
|
||||
}
|
||||
|
||||
static void CaptureGrassGlobals()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (StreamingBudgetField?.GetValue(null) is int budget)
|
||||
_originalStreamingBudget = budget;
|
||||
if (ForceNearbyField?.GetValue(null) is bool force)
|
||||
_originalForceNearby = force;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
static void TrackAndApply(NatureRenderer? renderer)
|
||||
{
|
||||
if (renderer == null || !renderer.gameObject.scene.IsValid()) return;
|
||||
int id = renderer.GetInstanceID();
|
||||
if (!OriginalRenderers.ContainsKey(id))
|
||||
{
|
||||
OriginalRenderers[id] = new RendererState
|
||||
{
|
||||
Renderer = new WeakReference<NatureRenderer>(renderer),
|
||||
OnlyInitializeWithinRenderingDistance =
|
||||
renderer.OnlyInitializeWithinRenderingDistance,
|
||||
};
|
||||
}
|
||||
renderer.OnlyInitializeWithinRenderingDistance =
|
||||
BaseGamePerfModule.Settings.distanceCullNatureTerrains;
|
||||
}
|
||||
|
||||
static void RestoreRendererSettings()
|
||||
{
|
||||
foreach (RendererState state in OriginalRenderers.Values)
|
||||
{
|
||||
if (!state.Renderer.TryGetTarget(out NatureRenderer? renderer) ||
|
||||
renderer == null)
|
||||
continue;
|
||||
renderer.OnlyInitializeWithinRenderingDistance =
|
||||
state.OnlyInitializeWithinRenderingDistance;
|
||||
}
|
||||
OriginalRenderers.Clear();
|
||||
}
|
||||
|
||||
static bool CorrectRangeCheck(
|
||||
NatureRenderer renderer,
|
||||
double threshold)
|
||||
{
|
||||
Terrain? terrain = renderer.Terrain;
|
||||
TerrainData? data = renderer.TerrainData;
|
||||
if (terrain == null || data == null) return false;
|
||||
|
||||
Bounds bounds = data.bounds;
|
||||
bounds.center += terrain.GetPosition();
|
||||
float configuredLimit = 5000f;
|
||||
try
|
||||
{
|
||||
if (RenderingDistanceLimitField?.GetValue(renderer) is float value)
|
||||
configuredLimit = value;
|
||||
}
|
||||
catch { }
|
||||
|
||||
foreach (CameraRenderer cameraRenderer in RendererPool.GetCameras())
|
||||
{
|
||||
Camera? camera = cameraRenderer?.Camera;
|
||||
if (camera == null ||
|
||||
camera.cameraType == CameraType.Preview ||
|
||||
camera.cameraType == CameraType.SceneView)
|
||||
continue;
|
||||
|
||||
Vector3 position = camera.transform.position;
|
||||
Vector3 center = bounds.center;
|
||||
Vector3 extents = bounds.extents;
|
||||
double dx = Math.Max(
|
||||
0.0, Math.Abs(position.x - center.x) - extents.x);
|
||||
double dy = Math.Max(
|
||||
0.0, Math.Abs(position.y - center.y) - extents.y);
|
||||
double dz = Math.Max(
|
||||
0.0, Math.Abs(position.z - center.z) - extents.z);
|
||||
double distance = Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
double limit = Math.Min(configuredLimit, camera.farClipPlane);
|
||||
if (distance < limit + threshold) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
[HarmonyPatch(
|
||||
typeof(TerrainGrassStreamer),
|
||||
nameof(TerrainGrassStreamer.SetStreamingBudget))]
|
||||
static class GrassBudgetSetterPatch
|
||||
{
|
||||
static void Prefix(ref int __0)
|
||||
{
|
||||
BaseGamePerfSettings settings = BaseGamePerfModule.Settings;
|
||||
if (!settings.natureStreamingSmoothingEnabled) return;
|
||||
__0 = Mathf.Min(
|
||||
__0, Mathf.Clamp(settings.grassInstanceBudgetPerFrame, 64, 4096));
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(
|
||||
typeof(TerrainGrassStreamer),
|
||||
nameof(TerrainGrassStreamer.SetForceLoadNearbyCells))]
|
||||
static class GrassNearbySetterPatch
|
||||
{
|
||||
static void Prefix(ref bool __0)
|
||||
{
|
||||
BaseGamePerfSettings settings = BaseGamePerfModule.Settings;
|
||||
if (settings.natureStreamingSmoothingEnabled &&
|
||||
settings.queueNearbyGrassLoads)
|
||||
__0 = false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(NatureRenderer), "OnEnable")]
|
||||
static class NatureRendererOnEnablePatch
|
||||
{
|
||||
static void Postfix(NatureRenderer __instance)
|
||||
{
|
||||
if (BaseGamePerfModule.Settings.natureStreamingSmoothingEnabled)
|
||||
TrackAndApply(__instance);
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(NatureRenderer), "IsInRangeOfAnyCamera")]
|
||||
static class NatureRendererRangePatch
|
||||
{
|
||||
static bool Prefix(
|
||||
NatureRenderer __instance,
|
||||
double __0,
|
||||
ref bool __result)
|
||||
{
|
||||
BaseGamePerfSettings settings = BaseGamePerfModule.Settings;
|
||||
if (!settings.natureStreamingSmoothingEnabled ||
|
||||
!settings.distanceCullNatureTerrains)
|
||||
return true;
|
||||
__result = CorrectRangeCheck(__instance, __0);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(TerrainGrassStreamer), "OnCellOutOfRange")]
|
||||
static class TerrainGrassExpiryPatch
|
||||
{
|
||||
static void Postfix(TerrainGrassStreamer __instance, int __1)
|
||||
{
|
||||
BaseGamePerfSettings settings = BaseGamePerfModule.Settings;
|
||||
int spread = Mathf.Clamp(settings.grassUnloadSpreadFrames, 0, 600);
|
||||
if (!settings.natureStreamingSmoothingEnabled ||
|
||||
spread <= 0 ||
|
||||
TimersField?.GetValue(__instance) is not int[] timers ||
|
||||
__1 < 0 ||
|
||||
__1 >= timers.Length)
|
||||
return;
|
||||
|
||||
int hash = unchecked(
|
||||
__instance.GetHashCode() * 397 ^ __1 * 7919) & int.MaxValue;
|
||||
timers[__1] += hash % (spread + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/Modules/CarCards/CarCardsModule.cs
Normal file
59
src/Modules/CarCards/CarCardsModule.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using System;
|
||||
using HarmonyLib;
|
||||
using S3.Core;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.CarCards;
|
||||
|
||||
public sealed class CarCardsModule : IModule
|
||||
{
|
||||
private const string SettingsFile = "S3.carcards.json";
|
||||
|
||||
public static CarCardsSettings Settings { get; private set; } = new();
|
||||
|
||||
private static Harmony? _harmony;
|
||||
private static GameObject? _hostGo;
|
||||
|
||||
public CarCardsModule() => Settings = SettingsStore.Load<CarCardsSettings>(SettingsFile);
|
||||
|
||||
public string Id => "carcards";
|
||||
public string DisplayName => "Car Cards";
|
||||
public string Description =>
|
||||
"A fanned handful of monopoly-style cards for the coupled cut you are working. " +
|
||||
"Color bands, waybill, notes, and on-card couple / handbrake / locate. Off by default.";
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get => Settings.enabled;
|
||||
set => Settings.enabled = value;
|
||||
}
|
||||
|
||||
public void OnEnable()
|
||||
{
|
||||
CardNotes.Load();
|
||||
_harmony = new Harmony("S3.carcards");
|
||||
foreach (Type t in new[] { typeof(CarCardsMouseOverUiPatch) })
|
||||
{
|
||||
try { _harmony.CreateClassProcessor(t).Patch(); }
|
||||
catch (Exception e) { Log.Error($"[carcards] patch {t.Name} failed: {e.Message}"); }
|
||||
}
|
||||
|
||||
_hostGo = new GameObject("[S3] CarCardsHost");
|
||||
UnityEngine.Object.DontDestroyOnLoad(_hostGo);
|
||||
_hostGo.AddComponent<CarCardsOverlay>();
|
||||
}
|
||||
|
||||
public void OnDisable()
|
||||
{
|
||||
CardNotes.Flush();
|
||||
_harmony?.UnpatchAll("S3.carcards");
|
||||
_harmony = null;
|
||||
if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo);
|
||||
_hostGo = null;
|
||||
}
|
||||
|
||||
public void SaveSettings() => Persist();
|
||||
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
|
||||
|
||||
public void DrawSettings() => CarCardsSettingsUI.Draw();
|
||||
}
|
||||
1270
src/Modules/CarCards/CarCardsOverlay.cs
Normal file
1270
src/Modules/CarCards/CarCardsOverlay.cs
Normal file
File diff suppressed because it is too large
Load diff
71
src/Modules/CarCards/CarCardsSettings.cs
Normal file
71
src/Modules/CarCards/CarCardsSettings.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using System;
|
||||
|
||||
namespace S3.Modules.CarCards;
|
||||
|
||||
[Serializable]
|
||||
public class CarCardsSettings
|
||||
{
|
||||
public bool enabled = false;
|
||||
public bool visible = true;
|
||||
|
||||
public int hotkeyKeyCode = 0;
|
||||
public int hotkeyModifiers = 0;
|
||||
|
||||
// 0 owner, 1 dest, 2 origin, 3 type, 4 paint, 5 mark
|
||||
public int colorMode = 1;
|
||||
|
||||
public float overlap = 0.55f;
|
||||
// Fraction of card width neighbors still cover when a card is lifted.
|
||||
public float hoverCover = 0.125f;
|
||||
public bool revealOnHover = true;
|
||||
public bool revealOnClick = true;
|
||||
|
||||
// 0 select, 1 follow, 2 inspector
|
||||
public int clickAction = 0;
|
||||
|
||||
public bool pinned;
|
||||
public string pinCarId = "";
|
||||
|
||||
public float windowX = -1f;
|
||||
public float windowY = -1f;
|
||||
public float windowW = 920f;
|
||||
public float windowH = 144f;
|
||||
|
||||
public bool cardsBehindTitle = true;
|
||||
public bool matchViewOrder = true;
|
||||
public bool freezeOrderAtDistance = true;
|
||||
public float viewOrderFreezeDistance = 1500f;
|
||||
public bool matchMapRotation = false;
|
||||
public bool showWaypointCuts = true;
|
||||
// 0 follow, 1 map, 2 both
|
||||
public int locateMode = 0;
|
||||
|
||||
// Parallel arrays (SettingsStore cannot nest custom classes).
|
||||
public string[] undockedIds = Array.Empty<string>();
|
||||
public float[] undockedX = Array.Empty<float>();
|
||||
public float[] undockedY = Array.Empty<float>();
|
||||
}
|
||||
|
||||
public enum CardColorMode
|
||||
{
|
||||
Owner = 0,
|
||||
Destination = 1,
|
||||
Origin = 2,
|
||||
Type = 3,
|
||||
Paint = 4,
|
||||
Mark = 5,
|
||||
}
|
||||
|
||||
public enum CardClickAction
|
||||
{
|
||||
Select = 0,
|
||||
Follow = 1,
|
||||
Inspector = 2,
|
||||
}
|
||||
|
||||
public enum CardLocateMode
|
||||
{
|
||||
Follow = 0,
|
||||
Map = 1,
|
||||
Both = 2,
|
||||
}
|
||||
153
src/Modules/CarCards/CarCardsSettingsUI.cs
Normal file
153
src/Modules/CarCards/CarCardsSettingsUI.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.CarCards;
|
||||
|
||||
static class CarCardsSettingsUI
|
||||
{
|
||||
internal static bool Capturing;
|
||||
|
||||
static readonly string[] ColorNames =
|
||||
{
|
||||
"Owner", "Destination", "Origin", "Type", "Paint", "Mark",
|
||||
};
|
||||
|
||||
static readonly string[] ClickNames =
|
||||
{
|
||||
"Select", "Follow", "Inspector",
|
||||
};
|
||||
|
||||
static readonly string[] LocateNames =
|
||||
{
|
||||
"Follow", "Show on map", "Both",
|
||||
};
|
||||
|
||||
public static void Draw()
|
||||
{
|
||||
var s = CarCardsModule.Settings;
|
||||
bool changed = false;
|
||||
|
||||
GUILayout.BeginVertical();
|
||||
GUILayout.Label("<b>Car Cards</b> - fanned consist dock over the game");
|
||||
GUILayout.Space(4f);
|
||||
GUILayout.Label(
|
||||
" Bound to the coupled cut of the selected car, or a pinned consist.\n" +
|
||||
" Title bar sits under the cards. Drag a card out to keep it on screen.\n" +
|
||||
" Enable the module, restart, then pick a hotkey (or leave the overlay on).",
|
||||
GUI.skin.label);
|
||||
|
||||
GUILayout.Space(10f);
|
||||
GUILayout.Label("<b>Hotkey</b>");
|
||||
GUILayout.Space(4f);
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label("Toggle overlay:", GUILayout.Width(110f));
|
||||
if (Capturing)
|
||||
{
|
||||
GUILayout.Label("Press a key… (Esc to cancel)");
|
||||
Event e = Event.current;
|
||||
if (e.type == EventType.KeyDown)
|
||||
{
|
||||
if (e.keyCode != KeyCode.Escape && e.keyCode != KeyCode.None)
|
||||
{
|
||||
s.hotkeyKeyCode = (int)e.keyCode;
|
||||
s.hotkeyModifiers = (e.shift ? 1 : 0) | (e.control ? 2 : 0) | (e.alt ? 4 : 0);
|
||||
changed = true;
|
||||
}
|
||||
Capturing = false;
|
||||
e.Use();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (GUILayout.Button($"{HotkeyLabel(s)} (click to change)", GUILayout.Width(240f)))
|
||||
Capturing = true;
|
||||
if (s.hotkeyKeyCode != 0 && GUILayout.Button("Clear", GUILayout.Width(60f)))
|
||||
{
|
||||
s.hotkeyKeyCode = 0;
|
||||
s.hotkeyModifiers = 0;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
GUILayout.EndHorizontal();
|
||||
|
||||
GUILayout.Space(10f);
|
||||
GUILayout.Label("<b>Display</b>");
|
||||
GUILayout.Space(4f);
|
||||
bool vis = GUILayout.Toggle(s.visible, " Show overlay when the module is enabled");
|
||||
if (vis != s.visible) { s.visible = vis; changed = true; }
|
||||
|
||||
bool match = GUILayout.Toggle(s.matchViewOrder, " Match view order (left card is the leftmost car on screen)");
|
||||
if (match != s.matchViewOrder) { s.matchViewOrder = match; changed = true; }
|
||||
|
||||
bool freeze = GUILayout.Toggle(s.freezeOrderAtDistance, " Freeze to lead-left when far (same as the on-screen consist, no orbit flip)");
|
||||
if (freeze != s.freezeOrderAtDistance) { s.freezeOrderAtDistance = freeze; changed = true; }
|
||||
if (s.freezeOrderAtDistance)
|
||||
{
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label($" Distance: {s.viewOrderFreezeDistance:F0}", GUILayout.Width(130f));
|
||||
float dist = GUILayout.HorizontalSlider(s.viewOrderFreezeDistance, 200f, 5000f, GUILayout.Width(220f));
|
||||
GUILayout.EndHorizontal();
|
||||
if (Mathf.Abs(dist - s.viewOrderFreezeDistance) > 1f) { s.viewOrderFreezeDistance = dist; changed = true; }
|
||||
}
|
||||
|
||||
bool mapRot = GUILayout.Toggle(s.matchMapRotation, " Sync to map rotation (when the map overlay or popout is open)");
|
||||
if (mapRot != s.matchMapRotation) { s.matchMapRotation = mapRot; changed = true; }
|
||||
|
||||
bool cuts = GUILayout.Toggle(s.showWaypointCuts, " Show waypoint cuts in the fan (needs WaypointQueue)");
|
||||
if (cuts != s.showWaypointCuts) { s.showWaypointCuts = cuts; changed = true; }
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label("Color band:", GUILayout.Width(110f));
|
||||
int color = GUILayout.Toolbar(s.colorMode, ColorNames, GUILayout.Width(420f));
|
||||
GUILayout.EndHorizontal();
|
||||
if (color != s.colorMode) { s.colorMode = color; changed = true; }
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label("Click a card:", GUILayout.Width(110f));
|
||||
int click = GUILayout.Toolbar(s.clickAction, ClickNames, GUILayout.Width(240f));
|
||||
GUILayout.EndHorizontal();
|
||||
if (click != s.clickAction) { s.clickAction = click; changed = true; }
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label("Locate button:", GUILayout.Width(110f));
|
||||
int loc = GUILayout.Toolbar(s.locateMode, LocateNames, GUILayout.Width(280f));
|
||||
GUILayout.EndHorizontal();
|
||||
if (loc != s.locateMode) { s.locateMode = loc; changed = true; }
|
||||
|
||||
GUILayout.Space(10f);
|
||||
GUILayout.Label("<b>Fan</b>");
|
||||
GUILayout.Space(4f);
|
||||
bool hover = GUILayout.Toggle(s.revealOnHover, " Lift card on hover");
|
||||
if (hover != s.revealOnHover) { s.revealOnHover = hover; changed = true; }
|
||||
bool clk = GUILayout.Toggle(s.revealOnClick, " Keep card lifted after click");
|
||||
if (clk != s.revealOnClick) { s.revealOnClick = clk; changed = true; }
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label($"Overlap: {s.overlap * 100f:F0}%", GUILayout.Width(110f));
|
||||
float ov = GUILayout.HorizontalSlider(s.overlap, 0f, 0.9f, GUILayout.Width(200f));
|
||||
GUILayout.EndHorizontal();
|
||||
if (Mathf.Abs(ov - s.overlap) > 0.01f) { s.overlap = ov; changed = true; }
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label($"Lifted cover: {s.hoverCover * 100f:F0}%", GUILayout.Width(110f));
|
||||
float hc = GUILayout.HorizontalSlider(s.hoverCover, 0f, 0.4f, GUILayout.Width(200f));
|
||||
GUILayout.EndHorizontal();
|
||||
GUILayout.Label(" How much neighbors still sit on a lifted card. 12% is about 1/8 width.", GUI.skin.label);
|
||||
if (Mathf.Abs(hc - s.hoverCover) > 0.005f) { s.hoverCover = hc; changed = true; }
|
||||
|
||||
GUILayout.EndVertical();
|
||||
|
||||
if (changed)
|
||||
CarCardsModule.Persist();
|
||||
}
|
||||
|
||||
internal static string HotkeyLabel(CarCardsSettings s)
|
||||
{
|
||||
if (s.hotkeyKeyCode == 0)
|
||||
return "Not set";
|
||||
string prefix = "";
|
||||
if ((s.hotkeyModifiers & 2) != 0) prefix += "Ctrl+";
|
||||
if ((s.hotkeyModifiers & 1) != 0) prefix += "Shift+";
|
||||
if ((s.hotkeyModifiers & 4) != 0) prefix += "Alt+";
|
||||
return prefix + (KeyCode)s.hotkeyKeyCode;
|
||||
}
|
||||
}
|
||||
78
src/Modules/CarCards/CardClick.cs
Normal file
78
src/Modules/CarCards/CardClick.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using Model;
|
||||
using S3.Modules.Popout;
|
||||
using Track;
|
||||
using UI.CarInspector;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.CarCards;
|
||||
|
||||
static class CardClick
|
||||
{
|
||||
public static void Apply(Car car)
|
||||
{
|
||||
if (car == null) return;
|
||||
try
|
||||
{
|
||||
switch ((CardClickAction)CarCardsModule.Settings.clickAction)
|
||||
{
|
||||
case CardClickAction.Follow:
|
||||
CameraSelector.shared?.FollowCar(car);
|
||||
break;
|
||||
case CardClickAction.Inspector:
|
||||
CarInspector.Show(car);
|
||||
break;
|
||||
default:
|
||||
if (TrainController.Shared != null)
|
||||
TrainController.Shared.SelectedCar = car;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static void Locate(Car car)
|
||||
{
|
||||
if (car == null) return;
|
||||
var mode = (CardLocateMode)CarCardsModule.Settings.locateMode;
|
||||
try
|
||||
{
|
||||
if (mode == CardLocateMode.Follow || mode == CardLocateMode.Both)
|
||||
CameraSelector.shared?.FollowCar(car);
|
||||
if (mode == CardLocateMode.Map || mode == CardLocateMode.Both)
|
||||
MapEnhancerBridge.JumpToCar(car);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static void JumpToWaypoint(WaypointDivider d)
|
||||
{
|
||||
if (d == null || !d.HasPosition) return;
|
||||
try
|
||||
{
|
||||
CameraSelector.shared?.JumpToPoint(
|
||||
d.Position,
|
||||
d.Rotation,
|
||||
CameraSelector.CameraIdentifier.Strategy);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static Car.LogicalEnd ScreenLeftEnd(Car car)
|
||||
{
|
||||
try
|
||||
{
|
||||
Camera? cam = CardViewOrder.ActiveCamera();
|
||||
var graph = Graph.Shared;
|
||||
if (cam == null || graph == null) return Car.LogicalEnd.A;
|
||||
Vector3 a = graph.GetPosition(car.WheelBoundsA);
|
||||
Vector3 b = graph.GetPosition(car.WheelBoundsB);
|
||||
return cam.WorldToScreenPoint(a).x <= cam.WorldToScreenPoint(b).x
|
||||
? Car.LogicalEnd.A
|
||||
: Car.LogicalEnd.B;
|
||||
}
|
||||
catch { return Car.LogicalEnd.A; }
|
||||
}
|
||||
|
||||
public static Car.LogicalEnd Other(Car.LogicalEnd end) =>
|
||||
end == Car.LogicalEnd.A ? Car.LogicalEnd.B : Car.LogicalEnd.A;
|
||||
}
|
||||
155
src/Modules/CarCards/CardNotes.cs
Normal file
155
src/Modules/CarCards/CardNotes.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
using Model;
|
||||
using S3.Core;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.CarCards;
|
||||
|
||||
/// <summary>
|
||||
/// Per-car notes. Prefers a KV key on the car so notes ride the save; never
|
||||
/// references KeyValue.Runtime at compile time (reflection + Harmony Traverse).
|
||||
/// Sidecar JSON is the fallback if KV get/set fails.
|
||||
/// </summary>
|
||||
static class CardNotes
|
||||
{
|
||||
public const string KvKey = "s3.card.notes";
|
||||
const string SidecarFile = "S3.carcards.notes.json";
|
||||
|
||||
static readonly Dictionary<string, string> _sidecar = new();
|
||||
static MethodInfo? _getItem;
|
||||
static MethodInfo? _setItem;
|
||||
static MethodInfo? _valueString;
|
||||
static PropertyInfo? _stringValue;
|
||||
static PropertyInfo? _valueType;
|
||||
static bool _resolved;
|
||||
static bool _kvBroken;
|
||||
static float _flushAt;
|
||||
static bool _dirty;
|
||||
|
||||
[Serializable]
|
||||
class FileShape
|
||||
{
|
||||
public string[] ids = Array.Empty<string>();
|
||||
public string[] texts = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public static void Load()
|
||||
{
|
||||
_sidecar.Clear();
|
||||
var file = SettingsStore.Load<FileShape>(SidecarFile);
|
||||
if (file.ids == null || file.texts == null) return;
|
||||
int n = Math.Min(file.ids.Length, file.texts.Length);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (string.IsNullOrEmpty(file.ids[i])) continue;
|
||||
_sidecar[file.ids[i]] = file.texts[i] ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
public static void Flush()
|
||||
{
|
||||
if (!_dirty) return;
|
||||
var ids = new string[_sidecar.Count];
|
||||
var texts = new string[_sidecar.Count];
|
||||
int i = 0;
|
||||
foreach (var kv in _sidecar)
|
||||
{
|
||||
ids[i] = kv.Key;
|
||||
texts[i] = kv.Value ?? "";
|
||||
i++;
|
||||
}
|
||||
SettingsStore.Save(SidecarFile, new FileShape { ids = ids, texts = texts });
|
||||
_dirty = false;
|
||||
_flushAt = 0f;
|
||||
}
|
||||
|
||||
public static void Tick()
|
||||
{
|
||||
if (_dirty && _flushAt > 0f && Time.unscaledTime >= _flushAt)
|
||||
Flush();
|
||||
}
|
||||
|
||||
public static string Get(Car car)
|
||||
{
|
||||
if (car == null) return "";
|
||||
if (!_kvBroken)
|
||||
{
|
||||
try
|
||||
{
|
||||
string? fromKv = ReadKv(car);
|
||||
if (!string.IsNullOrEmpty(fromKv))
|
||||
return fromKv;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_kvBroken = true;
|
||||
Log.Warn($"[carcards] notes KV read failed, using sidecar: {e.Message}");
|
||||
}
|
||||
}
|
||||
return _sidecar.TryGetValue(car.id, out string text) ? text : "";
|
||||
}
|
||||
|
||||
public static void Set(Car car, string text)
|
||||
{
|
||||
if (car == null) return;
|
||||
text ??= "";
|
||||
_sidecar[car.id] = text;
|
||||
_dirty = true;
|
||||
_flushAt = Time.unscaledTime + 0.6f;
|
||||
if (_kvBroken) return;
|
||||
try
|
||||
{
|
||||
WriteKv(car, text);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_kvBroken = true;
|
||||
Log.Warn($"[carcards] notes KV write failed, sidecar only: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static string? ReadKv(Car car)
|
||||
{
|
||||
object? kvo = AccessTools.Field(typeof(Car), "KeyValueObject")?.GetValue(car);
|
||||
if (kvo == null) return null;
|
||||
Resolve(kvo);
|
||||
if (_getItem == null) return null;
|
||||
object? val = _getItem.Invoke(kvo, new object[] { KvKey });
|
||||
if (val == null) return null;
|
||||
object? kind = _valueType?.GetValue(val);
|
||||
if (kind != null && string.Equals(kind.ToString(), "Null", StringComparison.Ordinal))
|
||||
return null;
|
||||
return _stringValue?.GetValue(val) as string;
|
||||
}
|
||||
|
||||
static void WriteKv(Car car, string text)
|
||||
{
|
||||
object? kvo = AccessTools.Field(typeof(Car), "KeyValueObject")?.GetValue(car);
|
||||
if (kvo == null) return;
|
||||
Resolve(kvo);
|
||||
if (_setItem == null || _valueString == null) return;
|
||||
object val = _valueString.Invoke(null, new object[] { text })!;
|
||||
_setItem.Invoke(kvo, new object[] { KvKey, val });
|
||||
}
|
||||
|
||||
static void Resolve(object kvo)
|
||||
{
|
||||
if (_resolved) return;
|
||||
Type t = kvo.GetType();
|
||||
Type? valueType = t.Assembly.GetType("KeyValue.Runtime.Value");
|
||||
_getItem = t.GetMethod("get_Item", new[] { typeof(string) });
|
||||
_setItem = valueType != null
|
||||
? t.GetMethod("set_Item", new[] { typeof(string), valueType })
|
||||
: null;
|
||||
if (valueType != null)
|
||||
{
|
||||
_valueString = valueType.GetMethod("String", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(string) }, null);
|
||||
_stringValue = valueType.GetProperty("StringValue");
|
||||
_valueType = valueType.GetProperty("Type");
|
||||
}
|
||||
_resolved = true;
|
||||
}
|
||||
}
|
||||
520
src/Modules/CarCards/CardUi.cs
Normal file
520
src/Modules/CarCards/CardUi.cs
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace S3.Modules.CarCards;
|
||||
|
||||
static class CardUi
|
||||
{
|
||||
public const float Round = 8f;
|
||||
|
||||
static Sprite? _white;
|
||||
static Sprite? _round;
|
||||
static Sprite? _pin;
|
||||
static Sprite? _dots;
|
||||
static Sprite? _x;
|
||||
static Sprite? _couple;
|
||||
static Sprite? _brake;
|
||||
static Sprite? _locate;
|
||||
static Sprite? _cut;
|
||||
static Sprite? _drop;
|
||||
static Sprite? _pickup;
|
||||
static TMP_FontAsset? _tmp;
|
||||
static Font? _uiFont;
|
||||
|
||||
public static Sprite White()
|
||||
{
|
||||
if (_white != null) return _white;
|
||||
var tex = Texture2D.whiteTexture;
|
||||
_white = Sprite.Create(tex, new Rect(0f, 0f, tex.width, tex.height), new Vector2(0.5f, 0.5f), 4f);
|
||||
_white.name = "S3CardWhite";
|
||||
return _white;
|
||||
}
|
||||
|
||||
public static Sprite RoundSprite()
|
||||
{
|
||||
if (_round != null) return _round;
|
||||
// 8 UI-unit corners. uGUI slice size is border * canvasRefPPU / spritePPU
|
||||
// (canvas reference PPU is 100), so sprite PPU must be aa * 100 to keep
|
||||
// the radius at 8 while the texture is supersampled.
|
||||
const int radiusUi = 8;
|
||||
const int aa = 8;
|
||||
const int r = radiusUi * aa;
|
||||
const int s = r * 2 + 32;
|
||||
const float ppu = aa * 100f;
|
||||
var tex = new Texture2D(s, s, TextureFormat.RGBA32, false)
|
||||
{
|
||||
wrapMode = TextureWrapMode.Clamp,
|
||||
filterMode = FilterMode.Bilinear,
|
||||
hideFlags = HideFlags.HideAndDontSave,
|
||||
};
|
||||
var px = new Color32[s * s];
|
||||
for (int y = 0; y < s; y++)
|
||||
{
|
||||
for (int x = 0; x < s; x++)
|
||||
{
|
||||
float a = CoverRound(x + 0.5f, y + 0.5f, s, r);
|
||||
byte b = (byte)Mathf.Clamp(Mathf.RoundToInt(a * 255f), 0, 255);
|
||||
px[y * s + x] = new Color32(255, 255, 255, b);
|
||||
}
|
||||
}
|
||||
tex.SetPixels32(px);
|
||||
tex.Apply(false, true);
|
||||
_round = Sprite.Create(
|
||||
tex, new Rect(0f, 0f, s, s), new Vector2(0.5f, 0.5f),
|
||||
ppu, 0, SpriteMeshType.FullRect, new Vector4(r, r, r, r));
|
||||
_round.name = "S3Round8";
|
||||
return _round;
|
||||
}
|
||||
|
||||
static float CoverRound(float x, float y, int s, int r)
|
||||
{
|
||||
float dx = x < r ? r - x : (x > s - r ? x - (s - r) : 0f);
|
||||
float dy = y < r ? r - y : (y > s - r ? y - (s - r) : 0f);
|
||||
if (dx == 0f || dy == 0f) return 1f;
|
||||
float d = Mathf.Sqrt(dx * dx + dy * dy);
|
||||
return Mathf.Clamp01(r + 0.5f - d);
|
||||
}
|
||||
|
||||
public static Sprite PinSprite()
|
||||
{
|
||||
if (_pin != null) return _pin;
|
||||
_pin = MakeGlyph(32, (tex, s) =>
|
||||
{
|
||||
FillCircle(tex, s, 16, 12, 7, Color.white);
|
||||
FillCircle(tex, s, 16, 12, 3, new Color(0, 0, 0, 0));
|
||||
FillTri(tex, s, 16, 16, 10, 28, 22, 16, Color.white);
|
||||
});
|
||||
_pin.name = "S3Pin";
|
||||
return _pin;
|
||||
}
|
||||
|
||||
public static Sprite DotsSprite()
|
||||
{
|
||||
if (_dots != null) return _dots;
|
||||
_dots = MakeGlyph(32, (tex, s) =>
|
||||
{
|
||||
FillCircle(tex, s, 16, 8, 3, Color.white);
|
||||
FillCircle(tex, s, 16, 16, 3, Color.white);
|
||||
FillCircle(tex, s, 16, 24, 3, Color.white);
|
||||
});
|
||||
_dots.name = "S3Dots";
|
||||
return _dots;
|
||||
}
|
||||
|
||||
public static Sprite XSprite()
|
||||
{
|
||||
if (_x != null) return _x;
|
||||
_x = MakeGlyph(32, (tex, s) =>
|
||||
{
|
||||
StrokeLine(tex, s, 8, 8, 24, 24, 2.2f, Color.white);
|
||||
StrokeLine(tex, s, 24, 8, 8, 24, 2.2f, Color.white);
|
||||
});
|
||||
_x.name = "S3X";
|
||||
return _x;
|
||||
}
|
||||
|
||||
public static Sprite CoupleSprite()
|
||||
{
|
||||
if (_couple != null) return _couple;
|
||||
_couple = MakeGlyph(32, (tex, s) =>
|
||||
{
|
||||
StrokeLine(tex, s, 6, 10, 14, 10, 2.2f, Color.white);
|
||||
StrokeLine(tex, s, 14, 10, 14, 22, 2.2f, Color.white);
|
||||
StrokeLine(tex, s, 14, 22, 6, 22, 2.2f, Color.white);
|
||||
StrokeLine(tex, s, 26, 10, 18, 10, 2.2f, Color.white);
|
||||
StrokeLine(tex, s, 18, 10, 18, 22, 2.2f, Color.white);
|
||||
StrokeLine(tex, s, 18, 22, 26, 22, 2.2f, Color.white);
|
||||
});
|
||||
_couple.name = "S3Couple";
|
||||
return _couple;
|
||||
}
|
||||
|
||||
public static Sprite BrakeSprite()
|
||||
{
|
||||
if (_brake != null) return _brake;
|
||||
_brake = MakeGlyph(32, (tex, s) =>
|
||||
{
|
||||
FillCircle(tex, s, 16, 14, 9, Color.white);
|
||||
FillCircle(tex, s, 16, 14, 5, new Color(0, 0, 0, 0));
|
||||
StrokeLine(tex, s, 16, 14, 16, 28, 2.2f, Color.white);
|
||||
});
|
||||
_brake.name = "S3Brake";
|
||||
return _brake;
|
||||
}
|
||||
|
||||
public static Sprite LocateSprite()
|
||||
{
|
||||
if (_locate != null) return _locate;
|
||||
_locate = MakeGlyph(32, (tex, s) =>
|
||||
{
|
||||
FillCircle(tex, s, 16, 16, 8, Color.white);
|
||||
FillCircle(tex, s, 16, 16, 4, new Color(0, 0, 0, 0));
|
||||
StrokeLine(tex, s, 16, 4, 16, 10, 2f, Color.white);
|
||||
StrokeLine(tex, s, 16, 22, 16, 28, 2f, Color.white);
|
||||
StrokeLine(tex, s, 4, 16, 10, 16, 2f, Color.white);
|
||||
StrokeLine(tex, s, 22, 16, 28, 16, 2f, Color.white);
|
||||
});
|
||||
_locate.name = "S3Locate";
|
||||
return _locate;
|
||||
}
|
||||
|
||||
public static Sprite CutSprite()
|
||||
{
|
||||
if (_cut != null) return _cut;
|
||||
_cut = MakeGlyph(64, (px, s) =>
|
||||
{
|
||||
StrokeCar(px, s, 4f, 18f, 22f, 28f, 2.6f);
|
||||
StrokeCar(px, s, 38f, 18f, 22f, 28f, 2.6f);
|
||||
});
|
||||
_cut.name = "S3Cut";
|
||||
return _cut;
|
||||
}
|
||||
|
||||
public static Sprite DropSprite()
|
||||
{
|
||||
if (_drop != null) return _drop;
|
||||
_drop = MakeGlyph(64, (px, s) =>
|
||||
{
|
||||
StrokeCar(px, s, 16f, 8f, 32f, 24f, 2.6f);
|
||||
StrokeLine(px, s, 32f, 36f, 32f, 50f, 2.8f, Color.white);
|
||||
FillTri(px, s, 32f, 58f, 21f, 46f, 43f, 46f, Color.white);
|
||||
});
|
||||
_drop.name = "S3Drop";
|
||||
return _drop;
|
||||
}
|
||||
|
||||
public static Sprite PickupSprite()
|
||||
{
|
||||
if (_pickup != null) return _pickup;
|
||||
_pickup = MakeGlyph(64, (px, s) =>
|
||||
{
|
||||
StrokeCar(px, s, 16f, 32f, 32f, 24f, 2.6f);
|
||||
StrokeLine(px, s, 32f, 28f, 32f, 14f, 2.8f, Color.white);
|
||||
FillTri(px, s, 32f, 6f, 21f, 18f, 43f, 18f, Color.white);
|
||||
});
|
||||
_pickup.name = "S3Pickup";
|
||||
return _pickup;
|
||||
}
|
||||
|
||||
static void StrokeCar(Color[] px, int s, float x, float y, float w, float h, float thick)
|
||||
{
|
||||
StrokeLine(px, s, x, y, x + w, y, thick, Color.white);
|
||||
StrokeLine(px, s, x + w, y, x + w, y + h, thick, Color.white);
|
||||
StrokeLine(px, s, x + w, y + h, x, y + h, thick, Color.white);
|
||||
StrokeLine(px, s, x, y + h, x, y, thick, Color.white);
|
||||
float wy = y + 4f;
|
||||
FillCircle(px, s, x + w * 0.28f, wy, 3.2f, Color.white);
|
||||
FillCircle(px, s, x + w * 0.72f, wy, 3.2f, Color.white);
|
||||
}
|
||||
|
||||
public static void TintButton(Button btn, bool on, Color onColor)
|
||||
{
|
||||
if (btn == null) return;
|
||||
var img = btn.targetGraphic as Image;
|
||||
if (img == null) return;
|
||||
img.color = on ? onColor : new Color(0.22f, 0.22f, 0.24f, 1f);
|
||||
}
|
||||
|
||||
static Sprite MakeGlyph(int s, System.Action<Color[], int> draw)
|
||||
{
|
||||
var tex = new Texture2D(s, s, TextureFormat.RGBA32, false)
|
||||
{
|
||||
filterMode = FilterMode.Bilinear,
|
||||
hideFlags = HideFlags.HideAndDontSave,
|
||||
};
|
||||
var px = new Color[s * s];
|
||||
draw(px, s);
|
||||
tex.SetPixels(px);
|
||||
tex.Apply(false, true);
|
||||
return Sprite.Create(tex, new Rect(0f, 0f, s, s), new Vector2(0.5f, 0.5f), 100f);
|
||||
}
|
||||
|
||||
static void FillCircle(Color[] px, int s, float cx, float cy, float r, Color c)
|
||||
{
|
||||
for (int y = 0; y < s; y++)
|
||||
for (int x = 0; x < s; x++)
|
||||
{
|
||||
float d = Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(cx, cy));
|
||||
float a = Mathf.Clamp01(r + 0.5f - d);
|
||||
if (a <= 0f) continue;
|
||||
int i = y * s + x;
|
||||
if (c.a <= 0.01f) px[i] = Color.clear;
|
||||
else
|
||||
{
|
||||
Color dcol = c;
|
||||
dcol.a *= a;
|
||||
px[i] = Blend(px[i], dcol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void FillTri(Color[] px, int s, float x1, float y1, float x2, float y2, float x3, float y3, Color c)
|
||||
{
|
||||
for (int y = 0; y < s; y++)
|
||||
for (int x = 0; x < s; x++)
|
||||
{
|
||||
float pxp = x + 0.5f, pyp = y + 0.5f;
|
||||
if (!InTri(pxp, pyp, x1, y1, x2, y2, x3, y3)) continue;
|
||||
px[y * s + x] = Blend(px[y * s + x], c);
|
||||
}
|
||||
}
|
||||
|
||||
static bool InTri(float px, float py, float x1, float y1, float x2, float y2, float x3, float y3)
|
||||
{
|
||||
float d1 = Sign(px, py, x1, y1, x2, y2);
|
||||
float d2 = Sign(px, py, x2, y2, x3, y3);
|
||||
float d3 = Sign(px, py, x3, y3, x1, y1);
|
||||
bool hasNeg = d1 < 0 || d2 < 0 || d3 < 0;
|
||||
bool hasPos = d1 > 0 || d2 > 0 || d3 > 0;
|
||||
return !(hasNeg && hasPos);
|
||||
}
|
||||
|
||||
static float Sign(float px, float py, float x1, float y1, float x2, float y2) =>
|
||||
(px - x2) * (y1 - y2) - (x1 - x2) * (py - y2);
|
||||
|
||||
static void StrokeLine(Color[] px, int s, float x0, float y0, float x1, float y1, float thick, Color c)
|
||||
{
|
||||
for (int y = 0; y < s; y++)
|
||||
for (int x = 0; x < s; x++)
|
||||
{
|
||||
float d = DistToSeg(x + 0.5f, y + 0.5f, x0, y0, x1, y1);
|
||||
float a = Mathf.Clamp01(thick + 0.5f - d);
|
||||
if (a <= 0f) continue;
|
||||
Color dcol = c;
|
||||
dcol.a *= a;
|
||||
px[y * s + x] = Blend(px[y * s + x], dcol);
|
||||
}
|
||||
}
|
||||
|
||||
static float DistToSeg(float px, float py, float x0, float y0, float x1, float y1)
|
||||
{
|
||||
float dx = x1 - x0, dy = y1 - y0;
|
||||
float l2 = dx * dx + dy * dy;
|
||||
if (l2 < 0.0001f) return Vector2.Distance(new Vector2(px, py), new Vector2(x0, y0));
|
||||
float t = Mathf.Clamp01(((px - x0) * dx + (py - y0) * dy) / l2);
|
||||
return Vector2.Distance(new Vector2(px, py), new Vector2(x0 + t * dx, y0 + t * dy));
|
||||
}
|
||||
|
||||
static Color Blend(Color under, Color over)
|
||||
{
|
||||
float a = over.a + under.a * (1f - over.a);
|
||||
if (a < 0.0001f) return Color.clear;
|
||||
return new Color(
|
||||
(over.r * over.a + under.r * under.a * (1f - over.a)) / a,
|
||||
(over.g * over.a + under.g * under.a * (1f - over.a)) / a,
|
||||
(over.b * over.a + under.b * under.a * (1f - over.a)) / a,
|
||||
a);
|
||||
}
|
||||
|
||||
public static TMP_FontAsset? TmpFont()
|
||||
{
|
||||
if (_tmp != null) return _tmp;
|
||||
try { _tmp = TMP_Settings.defaultFontAsset; } catch { }
|
||||
if (_tmp == null)
|
||||
{
|
||||
var all = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
|
||||
if (all != null && all.Length > 0) _tmp = all[0];
|
||||
}
|
||||
return _tmp;
|
||||
}
|
||||
|
||||
public static Font UiFont()
|
||||
{
|
||||
if (_uiFont != null) return _uiFont;
|
||||
_uiFont = Resources.GetBuiltinResource<Font>("Arial.ttf");
|
||||
return _uiFont;
|
||||
}
|
||||
|
||||
public static RectTransform Rt(GameObject go) => (RectTransform)go.transform;
|
||||
|
||||
public static Image MakeImage(RectTransform parent, string name, Color color, bool raycast, bool round = true)
|
||||
{
|
||||
var go = new GameObject(name, typeof(RectTransform), typeof(Image));
|
||||
var rt = Rt(go);
|
||||
rt.SetParent(parent, false);
|
||||
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
rt.pivot = new Vector2(0.5f, 0.5f);
|
||||
var img = go.GetComponent<Image>();
|
||||
if (round)
|
||||
{
|
||||
img.sprite = RoundSprite();
|
||||
img.type = Image.Type.Sliced;
|
||||
img.pixelsPerUnitMultiplier = 1f;
|
||||
}
|
||||
else
|
||||
{
|
||||
img.sprite = White();
|
||||
img.type = Image.Type.Simple;
|
||||
}
|
||||
img.color = color;
|
||||
img.raycastTarget = raycast;
|
||||
return img;
|
||||
}
|
||||
|
||||
public static Image MakeIcon(RectTransform parent, string name, Sprite sprite, Color color, bool raycast)
|
||||
{
|
||||
var go = new GameObject(name, typeof(RectTransform), typeof(Image));
|
||||
var rt = Rt(go);
|
||||
rt.SetParent(parent, false);
|
||||
var img = go.GetComponent<Image>();
|
||||
img.sprite = sprite;
|
||||
img.type = Image.Type.Simple;
|
||||
img.preserveAspect = true;
|
||||
img.color = color;
|
||||
img.raycastTarget = raycast;
|
||||
return img;
|
||||
}
|
||||
|
||||
public static TextMeshProUGUI Tmp(
|
||||
RectTransform parent, string name, float size, Color color,
|
||||
FontStyles style = FontStyles.Normal, TextAlignmentOptions align = TextAlignmentOptions.TopLeft)
|
||||
{
|
||||
var go = new GameObject(name, typeof(RectTransform), typeof(TextMeshProUGUI));
|
||||
var rt = Rt(go);
|
||||
rt.SetParent(parent, false);
|
||||
var tmp = go.GetComponent<TextMeshProUGUI>();
|
||||
tmp.fontSize = size;
|
||||
tmp.color = color;
|
||||
tmp.fontStyle = style;
|
||||
tmp.alignment = align;
|
||||
tmp.raycastTarget = false;
|
||||
tmp.textWrappingMode = TextWrappingModes.Normal;
|
||||
tmp.overflowMode = TextOverflowModes.Truncate;
|
||||
var font = TmpFont();
|
||||
if (font != null) tmp.font = font;
|
||||
return tmp;
|
||||
}
|
||||
|
||||
public static Button Button(RectTransform parent, string name, string label, Vector2 size)
|
||||
{
|
||||
var img = MakeImage(parent, name, new Color(0.22f, 0.22f, 0.24f, 1f), raycast: true);
|
||||
img.rectTransform.sizeDelta = size;
|
||||
var btn = img.gameObject.AddComponent<Button>();
|
||||
btn.targetGraphic = img;
|
||||
var colors = btn.colors;
|
||||
colors.highlightedColor = new Color(0.32f, 0.32f, 0.34f, 1f);
|
||||
colors.pressedColor = new Color(0.16f, 0.16f, 0.18f, 1f);
|
||||
btn.colors = colors;
|
||||
if (!string.IsNullOrEmpty(label))
|
||||
{
|
||||
var text = Tmp(img.rectTransform, "Label", 12f, Color.white, FontStyles.Normal, TextAlignmentOptions.Center);
|
||||
Stretch(text.rectTransform, 4f);
|
||||
text.text = label;
|
||||
text.raycastTarget = false;
|
||||
}
|
||||
return btn;
|
||||
}
|
||||
|
||||
public static Button IconButton(RectTransform parent, string name, Sprite icon, Vector2 size)
|
||||
{
|
||||
var btn = Button(parent, name, "", size);
|
||||
var img = MakeIcon(btn.GetComponent<RectTransform>(), "Icon", icon, Color.white, raycast: false);
|
||||
img.rectTransform.anchorMin = img.rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
img.rectTransform.sizeDelta = new Vector2(size.x * 0.55f, size.y * 0.55f);
|
||||
return btn;
|
||||
}
|
||||
|
||||
public static Toggle Toggle(RectTransform parent, string name, string label)
|
||||
{
|
||||
var img = MakeImage(parent, name, new Color(0.18f, 0.18f, 0.2f, 1f), raycast: true);
|
||||
var tog = img.gameObject.AddComponent<Toggle>();
|
||||
tog.targetGraphic = img;
|
||||
var check = MakeImage(img.rectTransform, "Check", new Color(0.45f, 0.72f, 0.42f, 1f), raycast: false);
|
||||
check.rectTransform.anchorMin = new Vector2(0f, 0.5f);
|
||||
check.rectTransform.anchorMax = new Vector2(0f, 0.5f);
|
||||
check.rectTransform.pivot = new Vector2(0f, 0.5f);
|
||||
check.rectTransform.anchoredPosition = new Vector2(6f, 0f);
|
||||
check.rectTransform.sizeDelta = new Vector2(12f, 12f);
|
||||
tog.graphic = check;
|
||||
var text = Tmp(img.rectTransform, "Label", 12f, Color.white, FontStyles.Normal, TextAlignmentOptions.MidlineLeft);
|
||||
text.rectTransform.anchorMin = new Vector2(0f, 0f);
|
||||
text.rectTransform.anchorMax = new Vector2(1f, 1f);
|
||||
text.rectTransform.offsetMin = new Vector2(22f, 0f);
|
||||
text.rectTransform.offsetMax = Vector2.zero;
|
||||
text.text = label;
|
||||
return tog;
|
||||
}
|
||||
|
||||
public static InputField NotesField(RectTransform parent, Vector2 size)
|
||||
{
|
||||
var img = MakeImage(parent, "Notes", new Color(0.97f, 0.94f, 0.86f, 1f), raycast: true);
|
||||
img.rectTransform.sizeDelta = size;
|
||||
var field = img.gameObject.AddComponent<InputField>();
|
||||
var textGo = new GameObject("Text", typeof(RectTransform), typeof(Text));
|
||||
var textRt = Rt(textGo);
|
||||
textRt.SetParent(img.rectTransform, false);
|
||||
Stretch(textRt, 4f);
|
||||
var text = textGo.GetComponent<Text>();
|
||||
text.font = UiFont();
|
||||
text.fontSize = 11;
|
||||
text.color = new Color(0.22f, 0.18f, 0.14f, 1f);
|
||||
text.supportRichText = false;
|
||||
text.alignment = TextAnchor.UpperLeft;
|
||||
text.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||
text.verticalOverflow = VerticalWrapMode.Truncate;
|
||||
field.textComponent = text;
|
||||
field.lineType = InputField.LineType.MultiLineNewline;
|
||||
field.customCaretColor = true;
|
||||
field.caretColor = new Color(0.2f, 0.15f, 0.1f, 1f);
|
||||
field.placeholder = null;
|
||||
return field;
|
||||
}
|
||||
|
||||
public static Image AddShadow(RectTransform host)
|
||||
{
|
||||
var sh = MakeImage(host, "Shadow", new Color(0f, 0f, 0f, 0.42f), raycast: false);
|
||||
Stretch(sh.rectTransform, 0f);
|
||||
sh.rectTransform.anchoredPosition = new Vector2(3f, -3f);
|
||||
sh.rectTransform.SetAsFirstSibling();
|
||||
return sh;
|
||||
}
|
||||
|
||||
public static Image AddResizeGrip(RectTransform panel, System.Action? onEnd, float minW, float minH)
|
||||
{
|
||||
var grip = MakeImage(panel, "Resize", new Color(1f, 1f, 1f, 0.28f), raycast: true);
|
||||
grip.rectTransform.anchorMin = grip.rectTransform.anchorMax = new Vector2(1f, 0f);
|
||||
grip.rectTransform.pivot = new Vector2(1f, 0f);
|
||||
grip.rectTransform.anchoredPosition = new Vector2(-3f, 3f);
|
||||
grip.rectTransform.sizeDelta = new Vector2(14f, 14f);
|
||||
grip.gameObject.AddComponent<PanelResize>().Bind(panel, onEnd, minW, minH);
|
||||
grip.transform.SetAsLastSibling();
|
||||
return grip;
|
||||
}
|
||||
|
||||
public static Scrollbar HScroll(RectTransform parent)
|
||||
{
|
||||
var bg = MakeImage(parent, "HScroll", new Color(0.12f, 0.12f, 0.14f, 0.7f), raycast: true);
|
||||
var handle = MakeImage(bg.rectTransform, "Handle", new Color(0.55f, 0.55f, 0.58f, 0.95f), raycast: true);
|
||||
Stretch(handle.rectTransform, 2f);
|
||||
var sb = bg.gameObject.AddComponent<Scrollbar>();
|
||||
sb.handleRect = handle.rectTransform;
|
||||
sb.targetGraphic = handle;
|
||||
sb.direction = Scrollbar.Direction.LeftToRight;
|
||||
sb.transition = Selectable.Transition.ColorTint;
|
||||
return sb;
|
||||
}
|
||||
|
||||
public static void Stretch(RectTransform rt, float pad)
|
||||
{
|
||||
rt.anchorMin = Vector2.zero;
|
||||
rt.anchorMax = Vector2.one;
|
||||
rt.offsetMin = new Vector2(pad, pad);
|
||||
rt.offsetMax = new Vector2(-pad, -pad);
|
||||
rt.pivot = new Vector2(0.5f, 0.5f);
|
||||
}
|
||||
|
||||
public static void BottomLeft(RectTransform rt, Vector2 pos, Vector2 size)
|
||||
{
|
||||
rt.anchorMin = rt.anchorMax = new Vector2(0f, 0f);
|
||||
rt.pivot = new Vector2(0f, 0f);
|
||||
rt.anchoredPosition = pos;
|
||||
rt.sizeDelta = size;
|
||||
}
|
||||
|
||||
public static Color BandInk(Color band)
|
||||
{
|
||||
float lum = band.r * 0.3f + band.g * 0.59f + band.b * 0.11f;
|
||||
return lum > 0.55f ? new Color(0.16f, 0.14f, 0.12f, 1f) : Color.white;
|
||||
}
|
||||
}
|
||||
239
src/Modules/CarCards/CardViewModel.cs
Normal file
239
src/Modules/CarCards/CardViewModel.cs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
using System;
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
using Model;
|
||||
using Model.Definition;
|
||||
using Model.Ops;
|
||||
using S3.Modules.QuickActions;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.CarCards;
|
||||
|
||||
sealed class CardViewModel
|
||||
{
|
||||
public Car Car = null!;
|
||||
public string Id = "";
|
||||
public string Mark = "";
|
||||
public string TypeLine = "";
|
||||
public string Specs = "";
|
||||
public string Load = "";
|
||||
public string Waybill = "";
|
||||
public string LocoExtra = "";
|
||||
public string Notes = "";
|
||||
public Color Band = new(0.45f, 0.45f, 0.48f);
|
||||
public bool Owned;
|
||||
|
||||
static MethodInfo? _getLoadInfo;
|
||||
static bool _loadResolved;
|
||||
|
||||
public static CardViewModel From(Car car, CardColorMode mode)
|
||||
{
|
||||
var vm = new CardViewModel { Car = car, Id = car.id };
|
||||
try { vm.Mark = string.IsNullOrEmpty(car.DisplayName) ? car.id : car.DisplayName; }
|
||||
catch { vm.Mark = car.id; }
|
||||
|
||||
string type = "";
|
||||
try { type = car.CarType; } catch { }
|
||||
string arch = "";
|
||||
try { arch = car.Archetype.DisplayName(); } catch { arch = car.Archetype.ToString(); }
|
||||
vm.TypeLine = string.IsNullOrEmpty(type) ? arch : $"{type} {arch}";
|
||||
|
||||
float ft = car.carLength * 3.28084f;
|
||||
float tons = car.Weight / 2000f;
|
||||
vm.Specs = $"{ft:0} ft {tons:0.0} T";
|
||||
|
||||
try { vm.Owned = car.IsOwnedByPlayer; } catch { vm.Owned = false; }
|
||||
vm.Load = ReadLoad(car);
|
||||
FillWaybill(car, vm);
|
||||
FillLoco(car, vm);
|
||||
vm.Notes = CardNotes.Get(car);
|
||||
vm.Band = BandColor(car, vm, mode);
|
||||
return vm;
|
||||
}
|
||||
|
||||
static void FillLoco(Car car, CardViewModel vm)
|
||||
{
|
||||
if (car is not BaseLocomotive loco) return;
|
||||
string rated = TrainReadout.Snapshot.FormatTe(loco.RatedTractiveEffort);
|
||||
string cur = TrainReadout.Snapshot.FormatTe(Mathf.Abs(loco.TractiveEffort));
|
||||
string fuel = "No fuel";
|
||||
try { fuel = loco.HasFuel ? "Fueled" : "No fuel"; } catch { }
|
||||
vm.LocoExtra = $"{rated} rated {cur} now\n{fuel}";
|
||||
}
|
||||
|
||||
static void FillWaybill(Car car, CardViewModel vm)
|
||||
{
|
||||
object? raw = null;
|
||||
try { raw = Traverse.Create(car).Property("Waybill").GetValue(); }
|
||||
catch { return; }
|
||||
raw = UnwrapNullable(raw);
|
||||
if (raw == null) return;
|
||||
|
||||
Type t = raw.GetType();
|
||||
object? origin = UnwrapNullable(t.GetField("Origin")?.GetValue(raw));
|
||||
object? dest = UnwrapNullable(t.GetField("Destination")?.GetValue(raw));
|
||||
string originName = PosName(origin);
|
||||
string destName = PosName(dest);
|
||||
int pay = 0;
|
||||
try { pay = (int)(t.GetField("PaymentOnArrival")?.GetValue(raw) ?? 0); }
|
||||
catch { }
|
||||
|
||||
if (string.IsNullOrEmpty(originName) && string.IsNullOrEmpty(destName))
|
||||
{
|
||||
vm.Waybill = "";
|
||||
return;
|
||||
}
|
||||
string route = string.IsNullOrEmpty(originName) ? destName : $"{originName} → {destName}";
|
||||
vm.Waybill = pay > 0 ? $"{route}\n${pay}" : route;
|
||||
}
|
||||
|
||||
static string PosName(object? pos)
|
||||
{
|
||||
if (pos == null) return "";
|
||||
try
|
||||
{
|
||||
object? n = pos.GetType().GetField("DisplayName")?.GetValue(pos);
|
||||
return n as string ?? "";
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
static Color BandColor(Car car, CardViewModel vm, CardColorMode mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
CardColorMode.Owner => vm.Owned
|
||||
? new Color(0.28f, 0.50f, 0.74f)
|
||||
: new Color(0.48f, 0.48f, 0.50f),
|
||||
CardColorMode.Destination => IndustryColor(car, dest: true),
|
||||
CardColorMode.Origin => IndustryColor(car, dest: false),
|
||||
CardColorMode.Type => TypeColor(car),
|
||||
CardColorMode.Paint => PaintColor(car),
|
||||
CardColorMode.Mark => MarkColor(vm.Mark),
|
||||
_ => new Color(0.45f, 0.45f, 0.48f),
|
||||
};
|
||||
}
|
||||
|
||||
static Color TypeColor(Car car)
|
||||
{
|
||||
try
|
||||
{
|
||||
return car.Archetype switch
|
||||
{
|
||||
CarArchetype.LocomotiveDiesel => new Color(0.82f, 0.68f, 0.18f),
|
||||
CarArchetype.LocomotiveSteam => new Color(0.55f, 0.38f, 0.22f),
|
||||
CarArchetype.Boxcar => new Color(0.78f, 0.42f, 0.18f),
|
||||
CarArchetype.Flat => new Color(0.45f, 0.62f, 0.38f),
|
||||
CarArchetype.Tank => new Color(0.22f, 0.55f, 0.62f),
|
||||
CarArchetype.HopperOpen => new Color(0.42f, 0.42f, 0.45f),
|
||||
CarArchetype.Caboose => new Color(0.72f, 0.22f, 0.22f),
|
||||
CarArchetype.Tender => new Color(0.35f, 0.32f, 0.30f),
|
||||
CarArchetype.Gondola => new Color(0.62f, 0.32f, 0.48f),
|
||||
CarArchetype.Coach => new Color(0.55f, 0.42f, 0.72f),
|
||||
CarArchetype.Baggage => new Color(0.38f, 0.45f, 0.62f),
|
||||
_ => new Color(0.50f, 0.50f, 0.52f),
|
||||
};
|
||||
}
|
||||
catch { return new Color(0.50f, 0.50f, 0.52f); }
|
||||
}
|
||||
|
||||
static Color MarkColor(string mark)
|
||||
{
|
||||
int h = mark.GetHashCode();
|
||||
float hue = Mathf.Abs(h % 360) / 360f;
|
||||
return Color.HSVToRGB(hue, 0.55f, 0.72f);
|
||||
}
|
||||
|
||||
static Color PaintColor(Car car)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var mb in car.GetComponentsInChildren<MonoBehaviour>(true))
|
||||
{
|
||||
if (mb == null || mb.GetType().Name != "CarColorController") continue;
|
||||
object? scheme = Traverse.Create(mb).Property("Scheme").GetValue();
|
||||
if (scheme == null) break;
|
||||
string? hex = Traverse.Create(scheme).Field("BaseHex").GetValue() as string;
|
||||
if (string.IsNullOrEmpty(hex)) break;
|
||||
if (!hex.StartsWith("#")) hex = "#" + hex;
|
||||
if (ColorUtility.TryParseHtmlString(hex, out Color c))
|
||||
return c;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return new Color(0.45f, 0.45f, 0.48f);
|
||||
}
|
||||
|
||||
static Color IndustryColor(Car car, bool dest)
|
||||
{
|
||||
object? raw = null;
|
||||
try { raw = Traverse.Create(car).Property("Waybill").GetValue(); }
|
||||
catch { return new Color(0.45f, 0.45f, 0.48f); }
|
||||
raw = UnwrapNullable(raw);
|
||||
if (raw == null) return new Color(0.45f, 0.45f, 0.48f);
|
||||
string field = dest ? "Destination" : "Origin";
|
||||
object? pos = UnwrapNullable(raw.GetType().GetField(field)?.GetValue(raw));
|
||||
if (pos == null) return new Color(0.45f, 0.45f, 0.48f);
|
||||
try
|
||||
{
|
||||
var ops = OpsController.Shared;
|
||||
if (ops == null) return new Color(0.45f, 0.45f, 0.48f);
|
||||
object? area = typeof(OpsController).GetMethod("AreaForCarPosition")?.Invoke(ops, new[] { pos });
|
||||
if (area == null) return new Color(0.45f, 0.45f, 0.48f);
|
||||
object? col = area.GetType().GetField("tagColor")?.GetValue(area);
|
||||
if (col is Color c) return c;
|
||||
}
|
||||
catch { }
|
||||
return new Color(0.45f, 0.45f, 0.48f);
|
||||
}
|
||||
|
||||
static string ReadLoad(Car car)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_loadResolved)
|
||||
{
|
||||
Type? ext = typeof(Car).Assembly.GetType("Model.Ops.CarExtensions");
|
||||
_getLoadInfo = ext?.GetMethod("GetLoadInfo", new[] { typeof(Car), typeof(int) });
|
||||
_loadResolved = true;
|
||||
}
|
||||
if (_getLoadInfo == null) return LoadFromWeight(car);
|
||||
|
||||
object? boxed = _getLoadInfo.Invoke(null, new object[] { car, 0 });
|
||||
boxed = UnwrapNullable(boxed);
|
||||
if (boxed == null) return LoadFromWeight(car);
|
||||
string? id = boxed.GetType().GetField("LoadId")?.GetValue(boxed) as string;
|
||||
object? qtyObj = boxed.GetType().GetField("Quantity")?.GetValue(boxed);
|
||||
float qty = qtyObj is float f ? f : 0f;
|
||||
if (string.IsNullOrEmpty(id) || qty <= 0.001f) return "Empty";
|
||||
return $"{id} {qty:0.#}";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return LoadFromWeight(car);
|
||||
}
|
||||
}
|
||||
|
||||
static string LoadFromWeight(Car car)
|
||||
{
|
||||
try
|
||||
{
|
||||
int empty = car.Definition.WeightEmpty;
|
||||
float extra = car.Weight - empty;
|
||||
if (extra > 200f) return "Loaded";
|
||||
}
|
||||
catch { }
|
||||
return "";
|
||||
}
|
||||
|
||||
static object? UnwrapNullable(object? raw)
|
||||
{
|
||||
if (raw == null) return null;
|
||||
Type t = raw.GetType();
|
||||
if (!t.IsGenericType || t.GetGenericTypeDefinition() != typeof(Nullable<>))
|
||||
return raw;
|
||||
object? has = t.GetProperty("HasValue")?.GetValue(raw);
|
||||
if (has is not true) return null;
|
||||
return t.GetProperty("Value")?.GetValue(raw);
|
||||
}
|
||||
}
|
||||
89
src/Modules/CarCards/CardViewOrder.cs
Normal file
89
src/Modules/CarCards/CardViewOrder.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using Model;
|
||||
using S3.Core.Ui;
|
||||
using S3.Modules.Popout;
|
||||
using UI.Map;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.CarCards;
|
||||
|
||||
static class CardViewOrder
|
||||
{
|
||||
public static Camera? ActiveCamera()
|
||||
{
|
||||
if (CarCardsModule.Settings.matchMapRotation && TryMapCamera(out Camera map))
|
||||
return map;
|
||||
return Camera.main;
|
||||
}
|
||||
|
||||
public static bool TryMapCamera(out Camera cam)
|
||||
{
|
||||
cam = null!;
|
||||
try
|
||||
{
|
||||
if (!UiService.IsOverlayVisible && !PopoutModule.IsDetached)
|
||||
return false;
|
||||
cam = MapBuilder.Shared?.mapCamera!;
|
||||
return cam != null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
cam = null!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsFar(Camera cam, List<Car> cars, float freezeAt, ref bool frozen)
|
||||
{
|
||||
float dist = MinDistance(cam, cars);
|
||||
if (dist < 0f) return frozen;
|
||||
float band = Mathf.Max(80f, freezeAt * 0.1f);
|
||||
if (frozen)
|
||||
{
|
||||
if (dist < freezeAt - band) frozen = false;
|
||||
}
|
||||
else if (dist >= freezeAt)
|
||||
frozen = true;
|
||||
return frozen;
|
||||
}
|
||||
|
||||
public static void OrderLeadLeft(List<Car> cars)
|
||||
{
|
||||
int lead = LeadIndex(cars);
|
||||
if (lead < 0) return;
|
||||
if (lead * 2 >= cars.Count)
|
||||
cars.Reverse();
|
||||
}
|
||||
|
||||
static int LeadIndex(List<Car> cars)
|
||||
{
|
||||
int firstLoco = -1;
|
||||
for (int i = 0; i < cars.Count; i++)
|
||||
{
|
||||
if (cars[i] is not BaseLocomotive loco) continue;
|
||||
if (firstLoco < 0) firstLoco = i;
|
||||
bool mu = false;
|
||||
try { mu = Traverse.Create(loco).Property<bool>("IsMuEnabled").Value; }
|
||||
catch { }
|
||||
if (!mu) return i;
|
||||
}
|
||||
return firstLoco;
|
||||
}
|
||||
|
||||
static float MinDistance(Camera cam, List<Car> cars)
|
||||
{
|
||||
float best = float.MaxValue;
|
||||
Vector3 p = cam.transform.position;
|
||||
for (int i = 0; i < cars.Count; i++)
|
||||
{
|
||||
var car = cars[i];
|
||||
if (car == null) continue;
|
||||
Transform body = car.BodyTransform != null ? car.BodyTransform : car.transform;
|
||||
if (body == null) continue;
|
||||
float d = Vector3.Distance(p, body.position);
|
||||
if (d < best) best = d;
|
||||
}
|
||||
return best < float.MaxValue ? best : -1f;
|
||||
}
|
||||
}
|
||||
276
src/Modules/CarCards/CardWidget.cs
Normal file
276
src/Modules/CarCards/CardWidget.cs
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
using Model;
|
||||
using S3.Modules.QuickActions;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace S3.Modules.CarCards;
|
||||
|
||||
sealed class CardWidget : MonoBehaviour,
|
||||
IPointerClickHandler, IBeginDragHandler, IDragHandler, IEndDragHandler
|
||||
{
|
||||
static readonly Color Cream = new(0.93f, 0.90f, 0.82f, 0.97f);
|
||||
static readonly Color Ink = new(0.16f, 0.14f, 0.12f, 1f);
|
||||
static readonly Color Border = new(0.12f, 0.10f, 0.08f, 0.95f);
|
||||
static readonly Color MadeUp = new(0.28f, 0.52f, 0.32f, 1f);
|
||||
static readonly Color BrakeOn = new(0.72f, 0.32f, 0.22f, 1f);
|
||||
|
||||
public CardViewModel? Model { get; private set; }
|
||||
public int Index;
|
||||
public bool Undocked { get; private set; }
|
||||
public bool Dragging { get; private set; }
|
||||
|
||||
Image _shadow = null!;
|
||||
Image _band = null!;
|
||||
TextMeshProUGUI _mark = null!;
|
||||
TextMeshProUGUI _body = null!;
|
||||
InputField? _notes;
|
||||
Button _left = null!;
|
||||
Button _brake = null!;
|
||||
Button _locate = null!;
|
||||
Button _right = null!;
|
||||
CarCardsOverlay? _owner;
|
||||
bool _didDrag;
|
||||
Vector3 _dragWorldOffset;
|
||||
|
||||
public static CardWidget Create(RectTransform parent, CarCardsOverlay owner)
|
||||
{
|
||||
var root = new GameObject("Card", typeof(RectTransform));
|
||||
var rt = (RectTransform)root.transform;
|
||||
rt.SetParent(parent, false);
|
||||
rt.sizeDelta = new Vector2(FanLayout.CardW, FanLayout.CardH);
|
||||
|
||||
var w = root.AddComponent<CardWidget>();
|
||||
w._owner = owner;
|
||||
|
||||
w._shadow = CardUi.AddShadow(rt);
|
||||
var border = CardUi.MakeImage(rt, "Border", Border, raycast: false);
|
||||
CardUi.Stretch(border.rectTransform, 0f);
|
||||
|
||||
var face = CardUi.MakeImage(rt, "Face", Cream, raycast: true);
|
||||
CardUi.Stretch(face.rectTransform, 2f);
|
||||
face.gameObject.AddComponent<Mask>().showMaskGraphic = true;
|
||||
|
||||
w._band = CardUi.MakeImage(face.rectTransform, "Band", Color.gray, raycast: false, round: false);
|
||||
var bandRt = w._band.rectTransform;
|
||||
bandRt.anchorMin = new Vector2(0f, 1f);
|
||||
bandRt.anchorMax = new Vector2(1f, 1f);
|
||||
bandRt.pivot = new Vector2(0.5f, 1f);
|
||||
bandRt.anchoredPosition = Vector2.zero;
|
||||
bandRt.sizeDelta = new Vector2(0f, FanLayout.BandH);
|
||||
|
||||
w._mark = CardUi.Tmp(bandRt, "Mark", 12f, Color.white, FontStyles.Bold, TextAlignmentOptions.MidlineLeft);
|
||||
w._mark.rectTransform.anchorMin = Vector2.zero;
|
||||
w._mark.rectTransform.anchorMax = Vector2.one;
|
||||
w._mark.rectTransform.offsetMin = new Vector2(6f, 0f);
|
||||
w._mark.rectTransform.offsetMax = new Vector2(-6f, 0f);
|
||||
|
||||
w._body = CardUi.Tmp(face.rectTransform, "Body", 11f, Ink, FontStyles.Normal, TextAlignmentOptions.TopLeft);
|
||||
w._body.rectTransform.anchorMin = new Vector2(0f, 0f);
|
||||
w._body.rectTransform.anchorMax = new Vector2(1f, 1f);
|
||||
w._body.rectTransform.offsetMin = new Vector2(8f, FanLayout.NotesH + FanLayout.ActionsH + 8f);
|
||||
w._body.rectTransform.offsetMax = new Vector2(-8f, -(FanLayout.BandH + 4f));
|
||||
w._body.color = Ink;
|
||||
|
||||
w._notes = CardUi.NotesField(face.rectTransform, new Vector2(0f, FanLayout.NotesH));
|
||||
NotesDragRelay.Attach(w._notes.gameObject, w);
|
||||
var nrt = (RectTransform)w._notes.transform;
|
||||
nrt.anchorMin = new Vector2(0f, 0f);
|
||||
nrt.anchorMax = new Vector2(1f, 0f);
|
||||
nrt.pivot = new Vector2(0.5f, 0f);
|
||||
nrt.anchoredPosition = new Vector2(0f, FanLayout.ActionsH + 6f);
|
||||
nrt.sizeDelta = new Vector2(-12f, FanLayout.NotesH);
|
||||
w._notes.onValueChanged.AddListener(t =>
|
||||
{
|
||||
if (w.Model?.Car == null) return;
|
||||
CardNotes.Set(w.Model.Car, t);
|
||||
w.Model.Notes = t;
|
||||
});
|
||||
|
||||
float by = 4f;
|
||||
float bw = 28f;
|
||||
w._left = ActionBtn(face.rectTransform, "L", CardUi.CoupleSprite(), 8f, by, bw);
|
||||
w._brake = ActionBtn(face.rectTransform, "Brake", CardUi.BrakeSprite(), 40f, by, bw);
|
||||
w._locate = ActionBtn(face.rectTransform, "Go", CardUi.LocateSprite(), 72f, by, bw);
|
||||
w._right = ActionBtn(face.rectTransform, "R", CardUi.CoupleSprite(), 104f, by, bw);
|
||||
w._left.onClick.AddListener(() => w.OnCouple(left: true));
|
||||
w._right.onClick.AddListener(() => w.OnCouple(left: false));
|
||||
w._brake.onClick.AddListener(w.OnBrake);
|
||||
w._locate.onClick.AddListener(w.OnLocate);
|
||||
return w;
|
||||
}
|
||||
|
||||
static Button ActionBtn(RectTransform parent, string name, Sprite icon, float x, float y, float size)
|
||||
{
|
||||
var btn = CardUi.IconButton(parent, name, icon, new Vector2(size, size));
|
||||
var rt = btn.GetComponent<RectTransform>();
|
||||
rt.anchorMin = rt.anchorMax = new Vector2(0f, 0f);
|
||||
rt.pivot = new Vector2(0f, 0f);
|
||||
rt.anchoredPosition = new Vector2(x, y);
|
||||
return btn;
|
||||
}
|
||||
|
||||
public void Bind(CardViewModel vm, int index)
|
||||
{
|
||||
Model = vm;
|
||||
Index = index;
|
||||
gameObject.SetActive(true);
|
||||
_band.color = vm.Band;
|
||||
_mark.color = CardUi.BandInk(vm.Band);
|
||||
_mark.text = vm.Mark;
|
||||
_body.text = BodyText(vm);
|
||||
name = "Card_" + vm.Mark;
|
||||
if (_notes != null && !_notes.isFocused && _notes.text != (vm.Notes ?? ""))
|
||||
_notes.text = vm.Notes ?? "";
|
||||
RefreshActions();
|
||||
}
|
||||
|
||||
public void RefreshActions()
|
||||
{
|
||||
var car = Model?.Car;
|
||||
if (car == null) return;
|
||||
var left = CardClick.ScreenLeftEnd(car);
|
||||
var right = CardClick.Other(left);
|
||||
CardUi.TintButton(_left, EndGearActions.IsMadeUp(car, left), MadeUp);
|
||||
CardUi.TintButton(_right, EndGearActions.IsMadeUp(car, right), MadeUp);
|
||||
bool brake = false;
|
||||
try { brake = car.air != null && car.air.handbrakeApplied; } catch { }
|
||||
CardUi.TintButton(_brake, brake, BrakeOn);
|
||||
_left.interactable = EndGearActions.CanToggleJoint(car, left);
|
||||
_right.interactable = EndGearActions.CanToggleJoint(car, right);
|
||||
}
|
||||
|
||||
public void SetUndocked(bool undocked) => Undocked = undocked;
|
||||
|
||||
public void SetLifted(bool lifted)
|
||||
{
|
||||
if (_shadow == null) return;
|
||||
_shadow.color = lifted ? new Color(0f, 0f, 0f, 0.55f) : new Color(0f, 0f, 0f, 0.42f);
|
||||
_shadow.rectTransform.anchoredPosition = lifted ? new Vector2(5f, -6f) : new Vector2(3f, -3f);
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
Model = null;
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
public void OnPointerClick(PointerEventData eventData)
|
||||
{
|
||||
if (eventData.button != PointerEventData.InputButton.Left) return;
|
||||
if (_didDrag) return;
|
||||
if (Undocked && eventData.clickCount >= 2)
|
||||
{
|
||||
_owner?.Redock(this, snap: false);
|
||||
return;
|
||||
}
|
||||
if (Model != null) _owner?.NotifyClick(this);
|
||||
}
|
||||
|
||||
public void OnBeginDrag(PointerEventData eventData)
|
||||
{
|
||||
if (eventData.button != PointerEventData.InputButton.Left) return;
|
||||
if (_notes != null && _notes.isFocused)
|
||||
_notes.DeactivateInputField();
|
||||
_didDrag = false;
|
||||
Dragging = true;
|
||||
var rt = (RectTransform)transform;
|
||||
Camera? cam = eventData.pressEventCamera;
|
||||
if (!RectTransformUtility.ScreenPointToWorldPointInRectangle(rt, eventData.position, cam, out Vector3 world))
|
||||
world = rt.position;
|
||||
_dragWorldOffset = rt.position - world;
|
||||
}
|
||||
|
||||
public void OnDrag(PointerEventData eventData)
|
||||
{
|
||||
if (!Dragging) return;
|
||||
if (!_didDrag && eventData.delta.sqrMagnitude > 1f)
|
||||
_didDrag = true;
|
||||
if (!Undocked && _didDrag)
|
||||
_owner?.BeginUndock(this);
|
||||
var rt = (RectTransform)transform;
|
||||
var parent = rt.parent as RectTransform;
|
||||
if (parent == null) return;
|
||||
Camera? cam = eventData.pressEventCamera;
|
||||
if (!RectTransformUtility.ScreenPointToWorldPointInRectangle(parent, eventData.position, cam, out Vector3 world))
|
||||
return;
|
||||
rt.anchorMin = rt.anchorMax = new Vector2(0f, 0f);
|
||||
rt.pivot = new Vector2(0f, 0f);
|
||||
rt.position = world + _dragWorldOffset;
|
||||
}
|
||||
|
||||
public void OnEndDrag(PointerEventData eventData)
|
||||
{
|
||||
bool was = Dragging;
|
||||
Dragging = false;
|
||||
if (!was) return;
|
||||
if (Undocked)
|
||||
_owner?.EndUndockDrag(this, eventData.position);
|
||||
}
|
||||
|
||||
void OnCouple(bool left)
|
||||
{
|
||||
var car = Model?.Car;
|
||||
if (car == null) return;
|
||||
var end = CardClick.ScreenLeftEnd(car);
|
||||
if (!left) end = CardClick.Other(end);
|
||||
EndGearActions.ToggleJoint(car, end);
|
||||
RefreshActions();
|
||||
}
|
||||
|
||||
void OnBrake()
|
||||
{
|
||||
var car = Model?.Car;
|
||||
if (car == null) return;
|
||||
try
|
||||
{
|
||||
bool on = car.air != null && car.air.handbrakeApplied;
|
||||
car.SetHandbrake(!on);
|
||||
}
|
||||
catch { }
|
||||
RefreshActions();
|
||||
}
|
||||
|
||||
void OnLocate()
|
||||
{
|
||||
if (Model?.Car != null) CardClick.Locate(Model.Car);
|
||||
}
|
||||
|
||||
static string BodyText(CardViewModel vm)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append(vm.TypeLine);
|
||||
sb.Append('\n').Append(vm.Specs);
|
||||
if (!string.IsNullOrEmpty(vm.Load))
|
||||
sb.Append('\n').Append(vm.Load);
|
||||
if (!string.IsNullOrEmpty(vm.Waybill))
|
||||
sb.Append('\n').Append(vm.Waybill);
|
||||
if (!string.IsNullOrEmpty(vm.LocoExtra))
|
||||
sb.Append('\n').Append(vm.LocoExtra);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
sealed class NotesDragRelay : MonoBehaviour,
|
||||
IInitializePotentialDragHandler, IBeginDragHandler, IDragHandler, IEndDragHandler
|
||||
{
|
||||
CardWidget _owner = null!;
|
||||
|
||||
public static void Attach(GameObject notes, CardWidget owner)
|
||||
{
|
||||
var relay = notes.AddComponent<NotesDragRelay>();
|
||||
relay._owner = owner;
|
||||
}
|
||||
|
||||
public void OnInitializePotentialDrag(PointerEventData eventData)
|
||||
{
|
||||
eventData.useDragThreshold = true;
|
||||
}
|
||||
|
||||
public void OnBeginDrag(PointerEventData eventData) => _owner.OnBeginDrag(eventData);
|
||||
|
||||
public void OnDrag(PointerEventData eventData) => _owner.OnDrag(eventData);
|
||||
|
||||
public void OnEndDrag(PointerEventData eventData) => _owner.OnEndDrag(eventData);
|
||||
}
|
||||
111
src/Modules/CarCards/ConsistBinder.cs
Normal file
111
src/Modules/CarCards/ConsistBinder.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
using System.Collections.Generic;
|
||||
using Model;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.CarCards;
|
||||
|
||||
sealed class ConsistBinder
|
||||
{
|
||||
public readonly struct Option
|
||||
{
|
||||
public readonly string Key;
|
||||
public readonly string Label;
|
||||
public readonly Car Anchor;
|
||||
|
||||
public Option(string key, string label, Car anchor)
|
||||
{
|
||||
Key = key;
|
||||
Label = label;
|
||||
Anchor = anchor;
|
||||
}
|
||||
}
|
||||
|
||||
public Car? Anchor { get; private set; }
|
||||
|
||||
public List<Car> Resolve()
|
||||
{
|
||||
var list = new List<Car>();
|
||||
var s = CarCardsModule.Settings;
|
||||
Car? seed = null;
|
||||
if (s.pinned && !string.IsNullOrEmpty(s.pinCarId))
|
||||
seed = Find(s.pinCarId);
|
||||
if (seed == null)
|
||||
{
|
||||
try { seed = TrainController.Shared?.SelectedCar; }
|
||||
catch { seed = null; }
|
||||
}
|
||||
Anchor = seed;
|
||||
if (seed == null) return list;
|
||||
try
|
||||
{
|
||||
foreach (Car c in seed.EnumerateCoupled())
|
||||
if (c != null) list.Add(c);
|
||||
}
|
||||
catch { /* partial consist */ }
|
||||
if (list.Count == 0) list.Add(seed);
|
||||
return list;
|
||||
}
|
||||
|
||||
public void PinTo(Car car)
|
||||
{
|
||||
var s = CarCardsModule.Settings;
|
||||
s.pinned = true;
|
||||
s.pinCarId = car.id;
|
||||
CarCardsModule.Persist();
|
||||
}
|
||||
|
||||
public void FollowSelection()
|
||||
{
|
||||
var s = CarCardsModule.Settings;
|
||||
s.pinned = false;
|
||||
s.pinCarId = "";
|
||||
CarCardsModule.Persist();
|
||||
}
|
||||
|
||||
public static List<Option> OwnedConsists()
|
||||
{
|
||||
var result = new List<Option>();
|
||||
var seen = new HashSet<string>();
|
||||
TrainController? tc = null;
|
||||
try { tc = TrainController.Shared; }
|
||||
catch { return result; }
|
||||
if (tc?.Cars == null) return result;
|
||||
|
||||
foreach (Car c in tc.Cars)
|
||||
{
|
||||
if (c is not BaseLocomotive) continue;
|
||||
bool owned = false;
|
||||
try { owned = c.IsOwnedByPlayer; }
|
||||
catch { continue; }
|
||||
if (!owned) continue;
|
||||
|
||||
var cars = new List<Car>();
|
||||
try
|
||||
{
|
||||
foreach (Car x in c.EnumerateCoupled())
|
||||
if (x != null) cars.Add(x);
|
||||
}
|
||||
catch { }
|
||||
if (cars.Count == 0) cars.Add(c);
|
||||
string key = cars[0].id;
|
||||
if (!seen.Add(key)) continue;
|
||||
string mark = string.IsNullOrEmpty(c.DisplayName) ? c.id : c.DisplayName;
|
||||
result.Add(new Option(c.id, $"{mark} · {cars.Count} cars", c));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static Car? Find(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id)) return null;
|
||||
try
|
||||
{
|
||||
var tc = TrainController.Shared;
|
||||
if (tc?.Cars == null) return null;
|
||||
foreach (Car c in tc.Cars)
|
||||
if (c != null && c.id == id) return c;
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
127
src/Modules/CarCards/DividerWidget.cs
Normal file
127
src/Modules/CarCards/DividerWidget.cs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace S3.Modules.CarCards;
|
||||
|
||||
sealed class DividerWidget : MonoBehaviour, IPointerClickHandler
|
||||
{
|
||||
static readonly Color Cream = new(0.93f, 0.90f, 0.82f, 0.97f);
|
||||
static readonly Color Ink = new(0.16f, 0.14f, 0.12f, 1f);
|
||||
static readonly Color Border = new(0.12f, 0.10f, 0.08f, 0.95f);
|
||||
|
||||
public WaypointDivider? Model { get; private set; }
|
||||
|
||||
Image _shadow = null!;
|
||||
Image _stripe = null!;
|
||||
TextMeshProUGUI _num = null!;
|
||||
Image _icon = null!;
|
||||
TextMeshProUGUI _head = null!;
|
||||
TextMeshProUGUI _body = null!;
|
||||
|
||||
public static DividerWidget Create(RectTransform parent)
|
||||
{
|
||||
var root = new GameObject("Divider", typeof(RectTransform));
|
||||
var rt = (RectTransform)root.transform;
|
||||
rt.SetParent(parent, false);
|
||||
rt.sizeDelta = new Vector2(FanLayout.DividerW, FanLayout.CardH);
|
||||
|
||||
var w = root.AddComponent<DividerWidget>();
|
||||
w._shadow = CardUi.AddShadow(rt);
|
||||
|
||||
var border = CardUi.MakeImage(rt, "Border", Border, raycast: false);
|
||||
CardUi.Stretch(border.rectTransform, 0f);
|
||||
|
||||
var face = CardUi.MakeImage(rt, "Face", Cream, raycast: true);
|
||||
CardUi.Stretch(face.rectTransform, 2f);
|
||||
face.gameObject.AddComponent<Mask>().showMaskGraphic = true;
|
||||
var faceRt = face.rectTransform;
|
||||
|
||||
w._stripe = CardUi.MakeImage(faceRt, "Stripe", Color.gray, raycast: false, round: false);
|
||||
var srt = w._stripe.rectTransform;
|
||||
srt.anchorMin = new Vector2(0f, 0f);
|
||||
srt.anchorMax = new Vector2(0f, 1f);
|
||||
srt.pivot = new Vector2(0f, 0.5f);
|
||||
srt.anchoredPosition = Vector2.zero;
|
||||
srt.sizeDelta = new Vector2(FanLayout.StripeW, 0f);
|
||||
|
||||
w._num = CardUi.Tmp(srt, "Num", 20f, Color.white, FontStyles.Bold, TextAlignmentOptions.Center);
|
||||
var nrt = w._num.rectTransform;
|
||||
nrt.anchorMin = new Vector2(0f, 1f);
|
||||
nrt.anchorMax = new Vector2(1f, 1f);
|
||||
nrt.pivot = new Vector2(0.5f, 1f);
|
||||
nrt.anchoredPosition = new Vector2(0f, -8f);
|
||||
nrt.sizeDelta = new Vector2(0f, 32f);
|
||||
w._num.enableAutoSizing = true;
|
||||
w._num.fontSizeMin = 11f;
|
||||
w._num.fontSizeMax = 20f;
|
||||
w._num.overflowMode = TextOverflowModes.Overflow;
|
||||
|
||||
var icon = CardUi.MakeIcon(srt, "Icon", CardUi.CutSprite(), Color.white, raycast: false);
|
||||
icon.rectTransform.anchorMin = icon.rectTransform.anchorMax = new Vector2(0.5f, 1f);
|
||||
icon.rectTransform.pivot = new Vector2(0.5f, 1f);
|
||||
icon.rectTransform.anchoredPosition = new Vector2(0f, -42f);
|
||||
icon.rectTransform.sizeDelta = new Vector2(22f, 22f);
|
||||
w._icon = icon;
|
||||
|
||||
w._head = CardUi.Tmp(faceRt, "Head", 13f, Ink, FontStyles.Bold, TextAlignmentOptions.TopLeft);
|
||||
var hrt = w._head.rectTransform;
|
||||
hrt.anchorMin = new Vector2(0f, 1f);
|
||||
hrt.anchorMax = new Vector2(1f, 1f);
|
||||
hrt.pivot = new Vector2(0f, 1f);
|
||||
hrt.anchoredPosition = new Vector2(FanLayout.StripeW + 8f, -8f);
|
||||
hrt.sizeDelta = new Vector2(-(FanLayout.StripeW + 16f), 36f);
|
||||
|
||||
w._body = CardUi.Tmp(faceRt, "Body", 11f, Ink, FontStyles.Normal, TextAlignmentOptions.TopLeft);
|
||||
var brt = w._body.rectTransform;
|
||||
brt.anchorMin = Vector2.zero;
|
||||
brt.anchorMax = Vector2.one;
|
||||
brt.offsetMin = new Vector2(FanLayout.StripeW + 8f, 8f);
|
||||
brt.offsetMax = new Vector2(-8f, -44f);
|
||||
w._body.color = Ink;
|
||||
return w;
|
||||
}
|
||||
|
||||
public void Bind(WaypointDivider d)
|
||||
{
|
||||
Model = d;
|
||||
gameObject.SetActive(true);
|
||||
name = "Div_" + d.Number;
|
||||
_stripe.color = d.Color;
|
||||
Color ink = CardUi.BandInk(d.Color);
|
||||
_num.color = ink;
|
||||
_num.text = d.Number.ToString();
|
||||
_icon.sprite = SpriteFor(d.Action);
|
||||
_icon.color = ink;
|
||||
_head.text = d.Headline ?? "";
|
||||
_body.text = d.Detail ?? "";
|
||||
}
|
||||
|
||||
public void SetLifted(bool lifted)
|
||||
{
|
||||
if (_shadow == null) return;
|
||||
_shadow.color = lifted ? new Color(0f, 0f, 0f, 0.55f) : new Color(0f, 0f, 0f, 0.42f);
|
||||
_shadow.rectTransform.anchoredPosition = lifted ? new Vector2(5f, -6f) : new Vector2(3f, -3f);
|
||||
}
|
||||
|
||||
public void OnPointerClick(PointerEventData eventData)
|
||||
{
|
||||
if (eventData.button != PointerEventData.InputButton.Left) return;
|
||||
if (Model != null) CardClick.JumpToWaypoint(Model);
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
Model = null;
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
static Sprite SpriteFor(WaypointAction action) => action switch
|
||||
{
|
||||
WaypointAction.Pickup => CardUi.PickupSprite(),
|
||||
WaypointAction.Drop => CardUi.DropSprite(),
|
||||
WaypointAction.Couple => CardUi.CoupleSprite(),
|
||||
_ => CardUi.CutSprite(),
|
||||
};
|
||||
}
|
||||
113
src/Modules/CarCards/FanLayout.cs
Normal file
113
src/Modules/CarCards/FanLayout.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.CarCards;
|
||||
|
||||
static class FanLayout
|
||||
{
|
||||
public const float CardW = 140f;
|
||||
public const float CardH = 236f;
|
||||
public const float DividerW = CardW;
|
||||
public const float BandH = 28f;
|
||||
public const float NotesH = 40f;
|
||||
public const float ActionsH = 26f;
|
||||
public const float TitleH = 36f;
|
||||
public const float WellMul = 3f;
|
||||
public const float PeekFrac = 1f / 3f;
|
||||
public const float HoverSpread = 36f;
|
||||
public const float AnimSpeed = 14f;
|
||||
public const float Pad = 8f;
|
||||
public const float ScrollH = 12f;
|
||||
public const float StripeW = 36f;
|
||||
|
||||
public static float DockH => TitleH * (1f + WellMul);
|
||||
public static float PeekHidden => CardH * (1f - PeekFrac);
|
||||
|
||||
public static float Step(float overlap) =>
|
||||
StepFor(CardW, overlap);
|
||||
|
||||
public static float StepFor(float width, float overlap) =>
|
||||
Mathf.Max(18f, width * (1f - Mathf.Clamp01(overlap)));
|
||||
|
||||
/// <summary>
|
||||
/// How far neighbors slide so a lifted card is only covered by hoverCover of CardW.
|
||||
/// Same pixel amount on both sides.
|
||||
/// </summary>
|
||||
public static float Parting(float overlap)
|
||||
{
|
||||
float restCover = Mathf.Max(0f, CardW - Step(overlap));
|
||||
float want = Mathf.Clamp(CarCardsModule.Settings.hoverCover, 0f, 0.5f) * CardW;
|
||||
return Mathf.Max(0f, restCover - want);
|
||||
}
|
||||
|
||||
public static float Span(int count, float overlap, int hover, int insertAt)
|
||||
{
|
||||
if (count <= 0) return Pad * 2f;
|
||||
float step = Step(overlap);
|
||||
float span = Pad + CardW + step * Mathf.Max(0, count - 1);
|
||||
if (insertAt >= 0) span += HoverSpread;
|
||||
if (hover >= 0) span += Parting(overlap) * 2f;
|
||||
return span + Pad;
|
||||
}
|
||||
|
||||
public static float XAt(IReadOnlyList<float> widths, int index, int hoverSlot, float overlap)
|
||||
{
|
||||
float x = Pad;
|
||||
for (int i = 0; i < index; i++)
|
||||
x += StepFor(widths[i], overlap);
|
||||
if (hoverSlot >= 0 && index != hoverSlot)
|
||||
{
|
||||
float part = Parting(overlap);
|
||||
if (index < hoverSlot) x -= part;
|
||||
else x += part;
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
public static float RestExclusiveRight(IReadOnlyList<float> widths, int index, float overlap)
|
||||
{
|
||||
if (widths == null || index < 0 || index >= widths.Count)
|
||||
return Pad;
|
||||
if (index + 1 < widths.Count)
|
||||
return XAt(widths, index + 1, -1, overlap);
|
||||
return XAt(widths, index, -1, overlap) + widths[index];
|
||||
}
|
||||
|
||||
public static float SpanOf(IReadOnlyList<float> widths, int hoverSlot, float overlap)
|
||||
{
|
||||
if (widths == null || widths.Count == 0) return Pad * 2f;
|
||||
int last = widths.Count - 1;
|
||||
float left = XAt(widths, 0, hoverSlot, overlap);
|
||||
float right = XAt(widths, last, hoverSlot, overlap) + widths[last];
|
||||
return right - Mathf.Min(left, 0f) + Pad;
|
||||
}
|
||||
|
||||
public static float SlotY(int slot, int hoverSlot, bool canLift) =>
|
||||
canLift && slot == hoverSlot && hoverSlot >= 0 ? Pad : -PeekHidden;
|
||||
|
||||
/// <summary>Bottom-left in fan space: y=0 is the top of title+scrollbar chrome.</summary>
|
||||
public static Vector2 DockedPos(
|
||||
int index, int count, int hover, int insertAt, float overlap)
|
||||
{
|
||||
float step = Step(overlap);
|
||||
float part = hover >= 0 ? Parting(overlap) : 0f;
|
||||
float x = Pad;
|
||||
for (int i = 0; i < index; i++)
|
||||
{
|
||||
x += step;
|
||||
if (i == insertAt) x += HoverSpread;
|
||||
}
|
||||
if (index == insertAt)
|
||||
x += HoverSpread * 0.5f;
|
||||
if (hover >= 0 && index != hover)
|
||||
{
|
||||
if (index < hover) x -= part;
|
||||
else x += part;
|
||||
}
|
||||
|
||||
float y = -PeekHidden;
|
||||
if (index == hover)
|
||||
y = Pad;
|
||||
return new Vector2(x, y);
|
||||
}
|
||||
}
|
||||
693
src/Modules/CarCards/WaypointCutSim.cs
Normal file
693
src/Modules/CarCards/WaypointCutSim.cs
Normal file
|
|
@ -0,0 +1,693 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using Model;
|
||||
using Model.Definition;
|
||||
using Model.Ops;
|
||||
using S3.Modules.Popout;
|
||||
using Track;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.CarCards;
|
||||
|
||||
enum WaypointAction
|
||||
{
|
||||
Cut,
|
||||
Drop,
|
||||
Pickup,
|
||||
Couple,
|
||||
}
|
||||
|
||||
sealed class WaypointDivider
|
||||
{
|
||||
public int Number;
|
||||
public Color Color;
|
||||
public WaypointAction Action;
|
||||
public string LeftId = "";
|
||||
public string RightId = "";
|
||||
/// <summary>
|
||||
/// When true, LeftId is the end car and RightId is the next car inward.
|
||||
/// The divider sits on the outer face of LeftId, away from RightId.
|
||||
/// </summary>
|
||||
public bool Outer;
|
||||
public string WaypointId = "";
|
||||
public bool HasPosition;
|
||||
public Vector3 Position;
|
||||
public Quaternion Rotation = Quaternion.identity;
|
||||
public string Headline = "";
|
||||
public string Detail = "";
|
||||
}
|
||||
|
||||
static class WaypointCutSim
|
||||
{
|
||||
const float NearCoupleM = 250f;
|
||||
|
||||
public static void Fill(IReadOnlyList<CardViewModel> cards, List<WaypointDivider> into, out string signature)
|
||||
{
|
||||
into.Clear();
|
||||
signature = "";
|
||||
if (cards == null || cards.Count == 0) return;
|
||||
if (!CarCardsModule.Settings.showWaypointCuts) return;
|
||||
if (!WaypointQueueBridge.IsInstalled) return;
|
||||
|
||||
BaseLocomotive? loco = LocoWithQueue(cards);
|
||||
if (loco == null) return;
|
||||
if (!WaypointQueueBridge.TryGetSnapshot(loco.id, out var snaps) || snaps.Count == 0)
|
||||
return;
|
||||
|
||||
Color color = MapWaypointSystem.ColorForLoco(loco.id);
|
||||
var present = new HashSet<string>(StringComparer.Ordinal);
|
||||
string seedId = "";
|
||||
for (int i = 0; i < cards.Count; i++)
|
||||
{
|
||||
string id = cards[i].Id;
|
||||
if (string.IsNullOrEmpty(id)) continue;
|
||||
present.Add(id);
|
||||
if (seedId.Length == 0) seedId = id;
|
||||
}
|
||||
if (present.Count == 0) return;
|
||||
|
||||
// Couple-walk order (A-end to B-end), not the camera fan. View reverse
|
||||
// only affects where dividers are drawn, not which cars they sit between.
|
||||
var remaining = IdsOf(seedId);
|
||||
remaining.RemoveAll(id => !present.Contains(id));
|
||||
if (remaining.Count == 0)
|
||||
{
|
||||
remaining = new List<string>(present.Count);
|
||||
for (int i = 0; i < cards.Count; i++)
|
||||
{
|
||||
string id = cards[i].Id;
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
remaining.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
var parked = new List<ParkedCut>();
|
||||
var sig = new System.Text.StringBuilder(loco.id);
|
||||
sig.Append(':').Append(snaps.Count);
|
||||
if (remaining.Count > 0)
|
||||
sig.Append(':').Append(remaining[0]).Append('-').Append(remaining[remaining.Count - 1]);
|
||||
|
||||
for (int w = 0; w < snaps.Count; w++)
|
||||
{
|
||||
var wp = snaps[w];
|
||||
sig.Append('|').Append(wp.Number)
|
||||
.Append(':').Append(wp.CouplingSearchMode)
|
||||
.Append(':').Append(wp.UncouplingMode)
|
||||
.Append(':').Append(wp.NumberOfCarsToCut)
|
||||
.Append(':').Append(wp.CoupleToCarId);
|
||||
|
||||
if (!Apply(wp, remaining, parked, present, into, color))
|
||||
break;
|
||||
}
|
||||
|
||||
signature = sig.ToString();
|
||||
}
|
||||
|
||||
static bool Apply(
|
||||
WaypointQueueBridge.WqWaypointSnap wp,
|
||||
List<string> remaining,
|
||||
List<ParkedCut> parked,
|
||||
HashSet<string> present,
|
||||
List<WaypointDivider> into,
|
||||
Color color)
|
||||
{
|
||||
if (remaining.Count == 0) return false;
|
||||
|
||||
bool coupling = WaypointQueueBridge.Coupling(wp);
|
||||
string coupleId = CoupleId(wp);
|
||||
|
||||
if (coupling && string.IsNullOrEmpty(coupleId)
|
||||
&& string.Equals(wp.CouplingSearchMode, "Nearest", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!TryResolveNearest(wp, remaining, parked, out coupleId))
|
||||
return false;
|
||||
}
|
||||
|
||||
if (coupling && string.IsNullOrEmpty(coupleId)
|
||||
&& string.Equals(wp.CouplingSearchMode, "SpecificCar", StringComparison.OrdinalIgnoreCase))
|
||||
coupleId = wp.CouplingSearchResultCarId ?? "";
|
||||
|
||||
if (Is(wp.UncouplingMode, "ByDestinationArea")
|
||||
|| Is(wp.UncouplingMode, "ByDestinationIndustry")
|
||||
|| Is(wp.UncouplingMode, "ByDestinationTrack"))
|
||||
return true;
|
||||
|
||||
if (coupling && wp.Pickup && Is(wp.UncouplingMode, "ByCount") && wp.NumberOfCarsToCut > 0)
|
||||
{
|
||||
if (string.IsNullOrEmpty(coupleId)) return false;
|
||||
bool onTrain = remaining.Contains(coupleId);
|
||||
if (!TryCoupleEnd(remaining, wp, coupleId, out _, out bool atStart))
|
||||
return false;
|
||||
if (!onTrain)
|
||||
AddEndDivider(into, wp, color, WaypointAction.Pickup, remaining, atStart);
|
||||
if (!TryMergeForeign(remaining, coupleId, atStart, wp.NumberOfCarsToCut, parked, wp))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (coupling && wp.Dropoff && Is(wp.UncouplingMode, "ByCount") && wp.NumberOfCarsToCut > 0)
|
||||
{
|
||||
if (string.IsNullOrEmpty(coupleId)) return false;
|
||||
if (!TryCoupleEnd(remaining, wp, coupleId, out _, out bool atStart))
|
||||
return false;
|
||||
if (!TryCutCount(remaining, wp.NumberOfCarsToCut, fromStart: atStart, parked, wp, into, color, WaypointAction.Drop, present))
|
||||
return remaining.Count > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (coupling && Is(wp.UncouplingMode, "BySpecificCar"))
|
||||
return true;
|
||||
|
||||
if (coupling)
|
||||
{
|
||||
if (string.IsNullOrEmpty(coupleId)) return false;
|
||||
bool onTrain = remaining.Contains(coupleId);
|
||||
if (!TryCoupleEnd(remaining, wp, coupleId, out _, out bool atStart))
|
||||
return false;
|
||||
if (!onTrain)
|
||||
AddEndDivider(into, wp, color, WaypointAction.Couple, remaining, atStart);
|
||||
if (!TryMergeForeign(remaining, coupleId, atStart, keep: -1, parked, wp))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Is(wp.UncouplingMode, "AllExceptLocomotives"))
|
||||
{
|
||||
CutAllExceptLocos(remaining, parked, wp, into, color, present);
|
||||
return remaining.Count > 0;
|
||||
}
|
||||
|
||||
if (Is(wp.UncouplingMode, "ByCount"))
|
||||
{
|
||||
bool fromStart = !TakeFromLast(remaining, wp);
|
||||
TryCutCount(remaining, wp.NumberOfCarsToCut, fromStart, parked, wp, into, color, WaypointAction.Cut, present);
|
||||
return remaining.Count > 0;
|
||||
}
|
||||
|
||||
if (Is(wp.UncouplingMode, "BySpecificCar"))
|
||||
{
|
||||
string spec = wp.UncouplingSearchResultCarId ?? "";
|
||||
if (string.IsNullOrEmpty(spec)) return true;
|
||||
int idx = remaining.IndexOf(spec);
|
||||
if (idx < 0) return true;
|
||||
bool fromStart = !TakeFromLast(remaining, wp);
|
||||
int n = fromStart ? idx + 1 : remaining.Count - idx;
|
||||
TryCutCount(remaining, n, fromStart, parked, wp, into, color, WaypointAction.Cut, present);
|
||||
return remaining.Count > 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static string CoupleId(WaypointQueueBridge.WqWaypointSnap wp)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(wp.CoupleToCarId)) return wp.CoupleToCarId;
|
||||
if (!string.IsNullOrEmpty(wp.CouplingSearchResultCarId)) return wp.CouplingSearchResultCarId;
|
||||
return "";
|
||||
}
|
||||
|
||||
static bool TryCutCount(
|
||||
List<string> remaining,
|
||||
int n,
|
||||
bool fromStart,
|
||||
List<ParkedCut> parked,
|
||||
WaypointQueueBridge.WqWaypointSnap wp,
|
||||
List<WaypointDivider> into,
|
||||
Color color,
|
||||
WaypointAction action,
|
||||
HashSet<string> present)
|
||||
{
|
||||
if (n <= 0 || n >= remaining.Count) return false;
|
||||
string left;
|
||||
string right;
|
||||
List<string> cut;
|
||||
if (fromStart)
|
||||
{
|
||||
left = remaining[n - 1];
|
||||
right = remaining[n];
|
||||
cut = remaining.GetRange(0, n);
|
||||
remaining.RemoveRange(0, n);
|
||||
}
|
||||
else
|
||||
{
|
||||
int keep = remaining.Count - n;
|
||||
left = remaining[keep - 1];
|
||||
right = remaining[keep];
|
||||
cut = remaining.GetRange(keep, n);
|
||||
remaining.RemoveRange(keep, n);
|
||||
}
|
||||
|
||||
Park(parked, cut, wp);
|
||||
if (present.Contains(left) || present.Contains(right))
|
||||
{
|
||||
into.Add(DividerFrom(wp, color, action, left, right));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static void CutAllExceptLocos(
|
||||
List<string> remaining,
|
||||
List<ParkedCut> parked,
|
||||
WaypointQueueBridge.WqWaypointSnap wp,
|
||||
List<WaypointDivider> into,
|
||||
Color color,
|
||||
HashSet<string> present)
|
||||
{
|
||||
var keep = new List<string>();
|
||||
var cut = new List<string>();
|
||||
for (int i = 0; i < remaining.Count; i++)
|
||||
{
|
||||
if (IsLocoType(remaining[i])) keep.Add(remaining[i]);
|
||||
else cut.Add(remaining[i]);
|
||||
}
|
||||
if (cut.Count == 0) return;
|
||||
|
||||
for (int i = 0; i < remaining.Count - 1; i++)
|
||||
{
|
||||
bool a = IsLocoType(remaining[i]);
|
||||
bool b = IsLocoType(remaining[i + 1]);
|
||||
if (a == b) continue;
|
||||
string left = remaining[i];
|
||||
string right = remaining[i + 1];
|
||||
if (!present.Contains(left) && !present.Contains(right)) continue;
|
||||
into.Add(DividerFrom(wp, color, WaypointAction.Cut, left, right));
|
||||
}
|
||||
|
||||
Park(parked, cut, wp);
|
||||
remaining.Clear();
|
||||
remaining.AddRange(keep);
|
||||
}
|
||||
|
||||
static void AddEndDivider(
|
||||
List<WaypointDivider> into,
|
||||
WaypointQueueBridge.WqWaypointSnap wp,
|
||||
Color color,
|
||||
WaypointAction action,
|
||||
List<string> remaining,
|
||||
bool atStart)
|
||||
{
|
||||
if (remaining.Count == 0) return;
|
||||
string end;
|
||||
string inward;
|
||||
if (atStart)
|
||||
{
|
||||
end = remaining[0];
|
||||
inward = remaining.Count > 1 ? remaining[1] : "";
|
||||
}
|
||||
else
|
||||
{
|
||||
end = remaining[remaining.Count - 1];
|
||||
inward = remaining.Count > 1 ? remaining[remaining.Count - 2] : "";
|
||||
}
|
||||
var d = DividerFrom(wp, color, action, end, inward);
|
||||
d.Outer = true;
|
||||
into.Add(d);
|
||||
}
|
||||
|
||||
static WaypointDivider DividerFrom(
|
||||
WaypointQueueBridge.WqWaypointSnap wp,
|
||||
Color color,
|
||||
WaypointAction action,
|
||||
string leftId,
|
||||
string rightId)
|
||||
{
|
||||
return new WaypointDivider
|
||||
{
|
||||
Number = wp.Number,
|
||||
Color = color,
|
||||
Action = action,
|
||||
LeftId = leftId,
|
||||
RightId = rightId,
|
||||
WaypointId = wp.Id ?? "",
|
||||
HasPosition = wp.HasPosition,
|
||||
Position = wp.Position,
|
||||
Rotation = wp.Rotation,
|
||||
Headline = Headline(action, wp),
|
||||
Detail = DetailLines(wp, action),
|
||||
};
|
||||
}
|
||||
|
||||
static string Headline(WaypointAction action, WaypointQueueBridge.WqWaypointSnap wp)
|
||||
{
|
||||
int n = wp.NumberOfCarsToCut;
|
||||
return action switch
|
||||
{
|
||||
WaypointAction.Pickup => n > 0 ? $"Pickup {n}" : "Pickup",
|
||||
WaypointAction.Drop => n > 0 ? $"Drop {n}" : "Drop",
|
||||
WaypointAction.Couple => "Couple",
|
||||
_ => n > 0 ? $"Cut {n}" : "Cut",
|
||||
};
|
||||
}
|
||||
|
||||
static string DetailLines(WaypointQueueBridge.WqWaypointSnap wp, WaypointAction action)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
if (!string.IsNullOrEmpty(wp.Name))
|
||||
lines.Add(wp.Name);
|
||||
string place = PlaceName(wp);
|
||||
if (!string.IsNullOrEmpty(place))
|
||||
lines.Add(place);
|
||||
string couple = CarMark(wp.CoupleToCarId);
|
||||
if (string.IsNullOrEmpty(couple))
|
||||
couple = CarMark(wp.CouplingSearchResultCarId);
|
||||
if (!string.IsNullOrEmpty(couple) && action != WaypointAction.Cut)
|
||||
lines.Add("To " + couple);
|
||||
if (action == WaypointAction.Cut && wp.NumberOfCarsToCut > 0)
|
||||
lines.Add(wp.CountFromNearest ? "Nearest end" : "Furthest end");
|
||||
if (wp.WillWait)
|
||||
lines.Add(wp.WaitMinutes > 0 ? $"Wait {wp.WaitMinutes} min" : "Wait");
|
||||
if (wp.WillRefuel)
|
||||
lines.Add(string.IsNullOrEmpty(wp.RefuelLoad) ? "Refuel" : "Refuel " + wp.RefuelLoad);
|
||||
if (!string.IsNullOrEmpty(wp.Notes))
|
||||
lines.Add(wp.Notes);
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
static string PlaceName(WaypointQueueBridge.WqWaypointSnap wp)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(wp.AreaName))
|
||||
return wp.AreaName;
|
||||
if (!wp.HasPosition) return "";
|
||||
try
|
||||
{
|
||||
var ops = OpsController.Shared;
|
||||
if (ops == null) return "";
|
||||
Area? area = ops.ClosestAreaForGamePosition(wp.Position);
|
||||
return area != null ? area.name : "";
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
static string CarMark(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id)) return "";
|
||||
Car? c = ConsistBinder.Find(id);
|
||||
if (c == null) return "";
|
||||
try
|
||||
{
|
||||
return string.IsNullOrEmpty(c.DisplayName) ? id : c.DisplayName;
|
||||
}
|
||||
catch { return id; }
|
||||
}
|
||||
|
||||
static bool TryCoupleEnd(
|
||||
List<string> remaining,
|
||||
WaypointQueueBridge.WqWaypointSnap wp,
|
||||
string coupleId,
|
||||
out string endId,
|
||||
out bool atStart)
|
||||
{
|
||||
endId = "";
|
||||
atStart = false;
|
||||
if (remaining.Count == 0) return false;
|
||||
int already = remaining.IndexOf(coupleId);
|
||||
if (already >= 0)
|
||||
{
|
||||
atStart = already * 2 < remaining.Count;
|
||||
endId = coupleId;
|
||||
return true;
|
||||
}
|
||||
|
||||
Vector3 hint = wp.HasPosition ? wp.Position : default;
|
||||
if (TryGamePosId(coupleId, out Vector3 couplePos))
|
||||
hint = couplePos;
|
||||
else if (!wp.HasPosition)
|
||||
return false;
|
||||
|
||||
atStart = !NearIsLast(remaining, hint);
|
||||
endId = atStart ? remaining[0] : remaining[remaining.Count - 1];
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool TryMergeForeign(
|
||||
List<string> remaining,
|
||||
string coupleId,
|
||||
bool atStart,
|
||||
int keep,
|
||||
List<ParkedCut> parked,
|
||||
WaypointQueueBridge.WqWaypointSnap wp)
|
||||
{
|
||||
if (remaining.Contains(coupleId)) return true;
|
||||
var foreign = IdsOf(coupleId);
|
||||
if (foreign.Count == 0) return false;
|
||||
foreign.RemoveAll(remaining.Contains);
|
||||
int ci = foreign.IndexOf(coupleId);
|
||||
if (ci < 0)
|
||||
{
|
||||
foreign.Insert(0, coupleId);
|
||||
ci = 0;
|
||||
}
|
||||
if (ci != 0 && ci != foreign.Count - 1)
|
||||
return false;
|
||||
if (ci == 0 && atStart) foreign.Reverse();
|
||||
if (ci != 0 && !atStart) foreign.Reverse();
|
||||
|
||||
if (keep >= 0 && keep < foreign.Count)
|
||||
{
|
||||
List<string> extra;
|
||||
List<string> take;
|
||||
if (atStart)
|
||||
{
|
||||
int drop = foreign.Count - keep;
|
||||
extra = foreign.GetRange(0, drop);
|
||||
take = foreign.GetRange(drop, keep);
|
||||
}
|
||||
else
|
||||
{
|
||||
take = foreign.GetRange(0, keep);
|
||||
extra = foreign.GetRange(keep, foreign.Count - keep);
|
||||
}
|
||||
if (extra.Count > 0) Park(parked, extra, wp);
|
||||
foreign = take;
|
||||
}
|
||||
|
||||
if (atStart) remaining.InsertRange(0, foreign);
|
||||
else remaining.AddRange(foreign);
|
||||
ForgetParked(parked, foreign);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool TryResolveNearest(
|
||||
WaypointQueueBridge.WqWaypointSnap wp,
|
||||
List<string> remaining,
|
||||
List<ParkedCut> parked,
|
||||
out string coupleId)
|
||||
{
|
||||
coupleId = "";
|
||||
if (!wp.HasPosition) return false;
|
||||
Vector3 wpPos = wp.Position;
|
||||
var remainingSet = new HashSet<string>(remaining);
|
||||
|
||||
string bestParked = "";
|
||||
float bestParkedD = NearCoupleM;
|
||||
for (int i = 0; i < parked.Count; i++)
|
||||
{
|
||||
var group = parked[i];
|
||||
if (!TryLiveGroup(group.Ids, remainingSet, out List<string> live, out float dist, wpPos))
|
||||
continue;
|
||||
if (dist >= bestParkedD) continue;
|
||||
bestParkedD = dist;
|
||||
bestParked = ClosestId(live, wpPos);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(bestParked))
|
||||
{
|
||||
coupleId = bestParked;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryPollNearby(wpPos, remainingSet, out string pollId))
|
||||
{
|
||||
coupleId = pollId;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool TryLiveGroup(
|
||||
List<string> ids,
|
||||
HashSet<string> remaining,
|
||||
out List<string> live,
|
||||
out float dist,
|
||||
Vector3 wpPos)
|
||||
{
|
||||
live = new List<string>();
|
||||
dist = float.MaxValue;
|
||||
string seed = "";
|
||||
for (int i = 0; i < ids.Count; i++)
|
||||
{
|
||||
if (remaining.Contains(ids[i])) continue;
|
||||
if (ConsistBinder.Find(ids[i]) == null) continue;
|
||||
seed = ids[i];
|
||||
break;
|
||||
}
|
||||
if (string.IsNullOrEmpty(seed)) return false;
|
||||
live = IdsOf(seed);
|
||||
if (live.Count == 0) return false;
|
||||
bool overlap = false;
|
||||
for (int i = 0; i < ids.Count; i++)
|
||||
{
|
||||
if (live.Contains(ids[i])) { overlap = true; break; }
|
||||
}
|
||||
if (!overlap) return false;
|
||||
string closest = ClosestId(live, wpPos);
|
||||
if (!TryGamePosId(closest, out Vector3 p)) return false;
|
||||
dist = Vector3.Distance(p, wpPos);
|
||||
return dist < NearCoupleM;
|
||||
}
|
||||
|
||||
static bool TryPollNearby(Vector3 wpPos, HashSet<string> remaining, out string id)
|
||||
{
|
||||
id = "";
|
||||
TrainController? tc = null;
|
||||
try { tc = TrainController.Shared; } catch { }
|
||||
if (tc?.Cars == null) return false;
|
||||
float best = NearCoupleM;
|
||||
foreach (Car c in tc.Cars)
|
||||
{
|
||||
if (c == null || remaining.Contains(c.id)) continue;
|
||||
bool a = false, b = false;
|
||||
try { a = c[Car.LogicalEnd.A].IsCoupled; } catch { }
|
||||
try { b = c[Car.LogicalEnd.B].IsCoupled; } catch { }
|
||||
if (a && b) continue;
|
||||
if (!TryGamePos(c, out Vector3 p)) continue;
|
||||
float d = Vector3.Distance(p, wpPos);
|
||||
if (d >= best) continue;
|
||||
best = d;
|
||||
id = c.id;
|
||||
}
|
||||
return !string.IsNullOrEmpty(id);
|
||||
}
|
||||
|
||||
static void Park(List<ParkedCut> parked, List<string> ids, WaypointQueueBridge.WqWaypointSnap wp)
|
||||
{
|
||||
if (ids == null || ids.Count == 0) return;
|
||||
var copy = new List<string>(ids);
|
||||
Vector3 pos = wp.HasPosition ? wp.Position : default;
|
||||
if (!wp.HasPosition)
|
||||
{
|
||||
for (int i = 0; i < copy.Count; i++)
|
||||
{
|
||||
if (TryGamePosId(copy[i], out pos)) break;
|
||||
}
|
||||
}
|
||||
parked.Add(new ParkedCut { Ids = copy, Pos = pos });
|
||||
}
|
||||
|
||||
static void ForgetParked(List<ParkedCut> parked, List<string> taken)
|
||||
{
|
||||
if (taken.Count == 0) return;
|
||||
var set = new HashSet<string>(taken);
|
||||
for (int i = parked.Count - 1; i >= 0; i--)
|
||||
{
|
||||
parked[i].Ids.RemoveAll(set.Contains);
|
||||
if (parked[i].Ids.Count == 0) parked.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
static bool TakeFromLast(List<string> remaining, WaypointQueueBridge.WqWaypointSnap wp)
|
||||
{
|
||||
bool nearLast = NearIsLast(remaining, wp.HasPosition ? wp.Position : default);
|
||||
return wp.CountFromNearest ? nearLast : !nearLast;
|
||||
}
|
||||
|
||||
static bool NearIsLast(List<string> remaining, Vector3 pos)
|
||||
{
|
||||
if (remaining.Count < 2) return true;
|
||||
if (!TryGamePosId(remaining[0], out Vector3 a)) return true;
|
||||
if (!TryGamePosId(remaining[remaining.Count - 1], out Vector3 b)) return true;
|
||||
return Vector3.Distance(b, pos) <= Vector3.Distance(a, pos);
|
||||
}
|
||||
|
||||
static List<string> IdsOf(string seedId)
|
||||
{
|
||||
var ids = new List<string>();
|
||||
Car? seed = ConsistBinder.Find(seedId);
|
||||
if (seed == null) return ids;
|
||||
try
|
||||
{
|
||||
foreach (Car c in seed.EnumerateCoupled())
|
||||
if (c != null) ids.Add(c.id);
|
||||
}
|
||||
catch { }
|
||||
if (ids.Count == 0) ids.Add(seedId);
|
||||
return ids;
|
||||
}
|
||||
|
||||
static string ClosestId(List<string> ids, Vector3 pos)
|
||||
{
|
||||
string best = ids[0];
|
||||
float bestD = float.MaxValue;
|
||||
for (int i = 0; i < ids.Count; i++)
|
||||
{
|
||||
if (!TryGamePosId(ids[i], out Vector3 p)) continue;
|
||||
float d = Vector3.Distance(p, pos);
|
||||
if (d >= bestD) continue;
|
||||
bestD = d;
|
||||
best = ids[i];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
static bool TryGamePosId(string id, out Vector3 pos)
|
||||
{
|
||||
pos = default;
|
||||
Car? c = ConsistBinder.Find(id);
|
||||
return c != null && TryGamePos(c, out pos);
|
||||
}
|
||||
|
||||
static bool TryGamePos(Car car, out Vector3 pos)
|
||||
{
|
||||
pos = default;
|
||||
try
|
||||
{
|
||||
if (car == null || Graph.Shared == null) return false;
|
||||
pos = Graph.Shared.GetPosition(car.WheelBoundsA);
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
static bool IsLocoType(string id)
|
||||
{
|
||||
Car? c = ConsistBinder.Find(id);
|
||||
if (c == null) return false;
|
||||
try
|
||||
{
|
||||
if (c.IsLocomotive) return true;
|
||||
var a = c.Archetype;
|
||||
return a == CarArchetype.LocomotiveDiesel
|
||||
|| a == CarArchetype.LocomotiveSteam
|
||||
|| a == CarArchetype.Tender;
|
||||
}
|
||||
catch { return c is BaseLocomotive; }
|
||||
}
|
||||
|
||||
static BaseLocomotive? LocoWithQueue(IReadOnlyList<CardViewModel> cards)
|
||||
{
|
||||
BaseLocomotive? lead = null;
|
||||
BaseLocomotive? withQ = null;
|
||||
for (int i = 0; i < cards.Count; i++)
|
||||
{
|
||||
if (cards[i].Car is not BaseLocomotive loco) continue;
|
||||
lead ??= loco;
|
||||
if (!WaypointQueueBridge.TryGetSnapshot(loco.id, out var snaps) || snaps.Count == 0)
|
||||
continue;
|
||||
bool mu = false;
|
||||
try { mu = Traverse.Create(loco).Property<bool>("IsMuEnabled").Value; }
|
||||
catch { }
|
||||
if (!mu) return loco;
|
||||
withQ ??= loco;
|
||||
}
|
||||
return withQ ?? lead;
|
||||
}
|
||||
|
||||
static bool Is(string value, string name) =>
|
||||
string.Equals(value, name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
sealed class ParkedCut
|
||||
{
|
||||
public List<string> Ids = new();
|
||||
public Vector3 Pos;
|
||||
}
|
||||
}
|
||||
1647
src/Modules/IndustryTags/IndustryCatalog.cs
Normal file
1647
src/Modules/IndustryTags/IndustryCatalog.cs
Normal file
File diff suppressed because it is too large
Load diff
816
src/Modules/IndustryTags/IndustryTagOverlay.cs
Normal file
816
src/Modules/IndustryTags/IndustryTagOverlay.cs
Normal file
|
|
@ -0,0 +1,816 @@
|
|||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using Helpers;
|
||||
using Model;
|
||||
using Model.Ops;
|
||||
using Track;
|
||||
using UI;
|
||||
using UI.Menu;
|
||||
using UI.Tags;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.IndustryTags;
|
||||
|
||||
sealed class IndustryTagOverlay : MonoBehaviour
|
||||
{
|
||||
public static bool PointerOver { get; internal set; }
|
||||
|
||||
readonly List<BusinessCluster> _clusters = new();
|
||||
readonly List<TrackSpot> _tracks = new();
|
||||
readonly Dictionary<string, IndustryTagView> _views = new();
|
||||
readonly Dictionary<string, IndustryTagView> _trackViews = new();
|
||||
readonly List<TrackSpot> _slideSpots = new();
|
||||
readonly List<IndustryTagView> _slideViews = new();
|
||||
readonly List<Rect> _slideRects = new();
|
||||
readonly List<BusinessCluster> _nextClusters = new();
|
||||
readonly List<TrackSpot> _nextTracks = new();
|
||||
readonly HashSet<string> _seen = new();
|
||||
readonly HashSet<string> _seenTracks = new();
|
||||
readonly List<string> _drop = new();
|
||||
float[] _slideDeltaT = System.Array.Empty<float>();
|
||||
float[] _slideDeltaLift = System.Array.Empty<float>();
|
||||
bool[] _slideColliding = System.Array.Empty<bool>();
|
||||
bool[] _slideNear = System.Array.Empty<bool>();
|
||||
float _rebuildAt;
|
||||
float _trackRebuildAt;
|
||||
float _refreshAt;
|
||||
float _slideAt;
|
||||
float _pointerAt;
|
||||
float _clickAt;
|
||||
bool _clustersInitialized;
|
||||
bool _tracksInitialized;
|
||||
bool _collisionDirty = true;
|
||||
int _lastSlideCount = -1;
|
||||
int _lastScreenWidth;
|
||||
int _lastScreenHeight;
|
||||
Vector3 _lastSlideCameraPos;
|
||||
Quaternion _lastSlideCameraRot;
|
||||
bool _haveSlideCameraPose;
|
||||
object? _playSession;
|
||||
bool _haveCatalogSettings;
|
||||
bool _catalogShowTracks;
|
||||
bool _catalogShowYards;
|
||||
float _catalogMergeDistance;
|
||||
IndustryTagView? _clickView;
|
||||
string _highlightKey = "";
|
||||
string? _highlightToken;
|
||||
static PersistentLoader? _loader;
|
||||
static GameObject? _loadingScreen;
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!InPlay())
|
||||
{
|
||||
HideImmediate();
|
||||
return;
|
||||
}
|
||||
|
||||
object? playSession = TrainController.Shared;
|
||||
if (!ReferenceEquals(_playSession, playSession))
|
||||
{
|
||||
ResetForPlaySession(playSession);
|
||||
}
|
||||
|
||||
Camera? cam = PlayCamera();
|
||||
bool want = WantVisible();
|
||||
if (!want)
|
||||
{
|
||||
FadeAll(cam);
|
||||
TickPointer(null);
|
||||
return;
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
var s = IndustryTagsModule.Settings;
|
||||
if (!_haveCatalogSettings
|
||||
|| _catalogShowTracks != s.showTrackBadges
|
||||
|| _catalogShowYards != s.showYardTags)
|
||||
{
|
||||
_haveCatalogSettings = true;
|
||||
_catalogShowTracks = s.showTrackBadges;
|
||||
_catalogShowYards = s.showYardTags;
|
||||
_tracksInitialized = false;
|
||||
_trackRebuildAt = 0f;
|
||||
}
|
||||
if (!Nearly(_catalogMergeDistance, s.mergeDistance))
|
||||
{
|
||||
_catalogMergeDistance = s.mergeDistance;
|
||||
_clustersInitialized = false;
|
||||
_rebuildAt = 0f;
|
||||
}
|
||||
bool rebuiltIndustries = false;
|
||||
bool rebuiltTracks = false;
|
||||
if (!_clustersInitialized || now >= _rebuildAt)
|
||||
{
|
||||
_rebuildAt = now + 30f;
|
||||
try
|
||||
{
|
||||
IndustryCatalog.Rebuild(_nextClusters, s.mergeDistance);
|
||||
_clusters.Clear();
|
||||
_clusters.AddRange(_nextClusters);
|
||||
_clustersInitialized = true;
|
||||
rebuiltIndustries = true;
|
||||
if (!_tracksInitialized)
|
||||
_trackRebuildAt = now + 0.05f;
|
||||
}
|
||||
catch (System.Exception ex) { S3.Core.Log.Error($"[industrytags] rebuild: {ex.Message}"); }
|
||||
}
|
||||
if (!rebuiltIndustries && (!_tracksInitialized || now >= _trackRebuildAt))
|
||||
{
|
||||
_trackRebuildAt = now + 30f;
|
||||
try
|
||||
{
|
||||
_nextTracks.Clear();
|
||||
if (s.showTrackBadges)
|
||||
IndustryCatalog.RebuildTracks(_nextTracks);
|
||||
if (s.showYardTags)
|
||||
IndustryCatalog.RebuildYards(_nextTracks);
|
||||
_tracks.Clear();
|
||||
_tracks.AddRange(_nextTracks);
|
||||
_tracksInitialized = true;
|
||||
rebuiltTracks = true;
|
||||
}
|
||||
catch (System.Exception ex) { S3.Core.Log.Error($"[industrytags] tracks: {ex.Message}"); }
|
||||
}
|
||||
|
||||
int detailBudget = 0;
|
||||
if (now >= _refreshAt)
|
||||
{
|
||||
_refreshAt = now + 0.04f;
|
||||
detailBudget = 1;
|
||||
}
|
||||
int createBudget = detailBudget;
|
||||
|
||||
Vector3 camGame = Vector3.zero;
|
||||
bool haveCam = TryCameraGame(cam, out camGame);
|
||||
|
||||
float maxDist = Mathf.Max(20f, s.maxDrawDistance);
|
||||
float trackDist = Mathf.Max(20f, s.trackMaxDrawDistance);
|
||||
bool hideIndustryNearTracks = s.showTrackBadges && s.hideIndustryWhenTracksVisible;
|
||||
_seen.Clear();
|
||||
|
||||
foreach (BusinessCluster cluster in _clusters)
|
||||
{
|
||||
if (string.IsNullOrEmpty(cluster.Key)) continue;
|
||||
_seen.Add(cluster.Key);
|
||||
|
||||
bool inRange = true;
|
||||
float distMul = 1f;
|
||||
if (haveCam)
|
||||
{
|
||||
float dist = Vector3.Distance(camGame, cluster.GameCentroid);
|
||||
inRange = dist <= maxDist;
|
||||
distMul = DistMul(dist, maxDist);
|
||||
if (hideIndustryNearTracks && dist <= trackDist)
|
||||
inRange = false;
|
||||
}
|
||||
|
||||
bool created = false;
|
||||
if (!_views.TryGetValue(cluster.Key, out IndustryTagView? view) || view == null)
|
||||
{
|
||||
if (!inRange || createBudget <= 0) continue;
|
||||
createBudget--;
|
||||
var go = new GameObject("IndustryTag " + cluster.Name);
|
||||
go.transform.SetParent(transform, false);
|
||||
view = go.AddComponent<IndustryTagView>();
|
||||
_views[cluster.Key] = view;
|
||||
created = true;
|
||||
}
|
||||
|
||||
if (!inRange)
|
||||
{
|
||||
view.SetDistanceMul(distMul);
|
||||
view.SetWanted(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (created || rebuiltIndustries)
|
||||
FillSpanIds(view, cluster.Components);
|
||||
if (created || (detailBudget > 0 && now >= view.DetailRefreshAt))
|
||||
{
|
||||
Color color = IndustryCatalog.TagColor(cluster.Area);
|
||||
if (view.Bind(cluster.Name, IndustryCatalog.BuildDetails(cluster, s), color))
|
||||
_collisionDirty = true;
|
||||
view.DetailRefreshAt = NextDetailRefresh(now, cluster.Key);
|
||||
detailBudget--;
|
||||
}
|
||||
Vector3 world = cluster.GameCentroid.GameToWorld();
|
||||
view.GamePos = cluster.GameCentroid;
|
||||
if (view.SetWorld(world))
|
||||
_collisionDirty = true;
|
||||
view.SetDistanceMul(distMul);
|
||||
view.SetWanted(true);
|
||||
}
|
||||
|
||||
_seenTracks.Clear();
|
||||
_slideSpots.Clear();
|
||||
_slideViews.Clear();
|
||||
bool drawTrackLayer = s.showTrackBadges || s.showYardTags;
|
||||
if (drawTrackLayer)
|
||||
{
|
||||
foreach (TrackSpot spot in _tracks)
|
||||
{
|
||||
if (string.IsNullOrEmpty(spot.Key)) continue;
|
||||
bool wantThis = spot.Yard ? s.showYardTags : s.showTrackBadges;
|
||||
if (!wantThis) continue;
|
||||
_seenTracks.Add(spot.Key);
|
||||
|
||||
bool inRange = true;
|
||||
float distMul = 1f;
|
||||
if (haveCam)
|
||||
{
|
||||
float dist = Vector3.Distance(camGame, spot.GameCentroid);
|
||||
inRange = dist <= trackDist;
|
||||
distMul = DistMul(dist, trackDist);
|
||||
}
|
||||
|
||||
bool created = false;
|
||||
if (!_trackViews.TryGetValue(spot.Key, out IndustryTagView? view) || view == null)
|
||||
{
|
||||
if (!inRange || createBudget <= 0) continue;
|
||||
createBudget--;
|
||||
var go = new GameObject((spot.Yard ? "YardTag " : "TrackBadge ") + spot.Label);
|
||||
go.transform.SetParent(transform, false);
|
||||
view = go.AddComponent<IndustryTagView>();
|
||||
view.TrackBadge = true;
|
||||
view.Yard = spot.Yard;
|
||||
_trackViews[spot.Key] = view;
|
||||
created = true;
|
||||
}
|
||||
|
||||
view.Yard = spot.Yard;
|
||||
if (!inRange)
|
||||
{
|
||||
view.TrackT = 0.5f;
|
||||
view.HeightLift = 0f;
|
||||
view.SetDistanceMul(distMul);
|
||||
view.SetWanted(false);
|
||||
continue;
|
||||
}
|
||||
if (created || rebuiltTracks)
|
||||
{
|
||||
view.SpanIds.Clear();
|
||||
view.SpanIds.AddRange(spot.SpanIds);
|
||||
view.HighlightKey = string.Join("|", view.SpanIds);
|
||||
}
|
||||
if (created || (detailBudget > 0 && now >= view.DetailRefreshAt))
|
||||
{
|
||||
Color color = IndustryCatalog.TagColor(spot.Area);
|
||||
if (view.Bind(IndustryCatalog.TrackTitle(spot), IndustryCatalog.BuildTrackDetails(spot, s), color, trackBadge: true))
|
||||
_collisionDirty = true;
|
||||
view.DetailRefreshAt = NextDetailRefresh(now, spot.Key);
|
||||
detailBudget--;
|
||||
}
|
||||
|
||||
Vector3 game = spot.GameCentroid;
|
||||
if (spot.PathLength >= 8f)
|
||||
{
|
||||
view.TrackT = IndustryCatalog.ClampPathT(spot, view.TrackT);
|
||||
game = IndustryCatalog.PointOnPath(spot, view.TrackT);
|
||||
}
|
||||
Vector3 world = game.GameToWorld();
|
||||
view.GamePos = game;
|
||||
if (view.SetWorld(world))
|
||||
_collisionDirty = true;
|
||||
view.SetDistanceMul(distMul);
|
||||
view.SetWanted(true);
|
||||
_slideSpots.Add(spot);
|
||||
_slideViews.Add(view);
|
||||
}
|
||||
}
|
||||
|
||||
SlideTrackBadges(cam, now);
|
||||
|
||||
TickAppearances(cam);
|
||||
|
||||
TickPointerBounded(cam, now);
|
||||
|
||||
if (rebuiltIndustries && _views.Count > _seen.Count)
|
||||
{
|
||||
_drop.Clear();
|
||||
foreach (var kv in _views)
|
||||
{
|
||||
if (_seen.Contains(kv.Key)) continue;
|
||||
_drop.Add(kv.Key);
|
||||
if (kv.Value != null) Destroy(kv.Value.gameObject);
|
||||
}
|
||||
foreach (string key in _drop)
|
||||
_views.Remove(key);
|
||||
}
|
||||
|
||||
if ((rebuiltTracks && _trackViews.Count > _seenTracks.Count) || !drawTrackLayer)
|
||||
{
|
||||
_drop.Clear();
|
||||
foreach (var kv in _trackViews)
|
||||
{
|
||||
if (drawTrackLayer && _seenTracks.Contains(kv.Key)) continue;
|
||||
_drop.Add(kv.Key);
|
||||
if (kv.Value != null) Destroy(kv.Value.gameObject);
|
||||
}
|
||||
foreach (string key in _drop)
|
||||
_trackViews.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
static float NextDetailRefresh(float now, string key)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
int hash = 17;
|
||||
for (int i = 0; i < key.Length; i++)
|
||||
hash = hash * 31 + key[i];
|
||||
float phase = (hash & 255) / 255f;
|
||||
return now + 0.85f + phase * 0.25f;
|
||||
}
|
||||
}
|
||||
|
||||
void TickAppearances(Camera? cam)
|
||||
{
|
||||
int index = 0;
|
||||
int frame = Time.frameCount;
|
||||
foreach (var kv in _views)
|
||||
{
|
||||
IndustryTagView? view = kv.Value;
|
||||
if (view == null || !view.gameObject.activeSelf) continue;
|
||||
view.TickAppearance(cam, ((frame + index++) & 3) == 0);
|
||||
}
|
||||
foreach (var kv in _trackViews)
|
||||
{
|
||||
IndustryTagView? view = kv.Value;
|
||||
if (view == null || !view.gameObject.activeSelf) continue;
|
||||
view.TickAppearance(cam, ((frame + index++) & 3) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
void TickPointerBounded(Camera? cam, float now)
|
||||
{
|
||||
bool click = Input.GetMouseButtonDown(0);
|
||||
if (!click && now < _pointerAt) return;
|
||||
_pointerAt = now + 0.04f;
|
||||
TickPointer(cam);
|
||||
}
|
||||
|
||||
void SlideTrackBadges(Camera? cam, float now)
|
||||
{
|
||||
int n = _slideSpots.Count;
|
||||
if (cam == null || n == 0) return;
|
||||
bool cameraChanged = !_haveSlideCameraPose
|
||||
|| (cam.transform.position - _lastSlideCameraPos).sqrMagnitude > 0.0025f
|
||||
|| Quaternion.Angle(cam.transform.rotation, _lastSlideCameraRot) > 0.1f
|
||||
|| Screen.width != _lastScreenWidth
|
||||
|| Screen.height != _lastScreenHeight;
|
||||
bool listChanged = n != _lastSlideCount;
|
||||
if (now < _slideAt || (!cameraChanged && !listChanged && !_collisionDirty))
|
||||
return;
|
||||
_slideAt = now + 0.1f;
|
||||
_haveSlideCameraPose = true;
|
||||
_lastSlideCameraPos = cam.transform.position;
|
||||
_lastSlideCameraRot = cam.transform.rotation;
|
||||
_lastScreenWidth = Screen.width;
|
||||
_lastScreenHeight = Screen.height;
|
||||
_lastSlideCount = n;
|
||||
_collisionDirty = false;
|
||||
|
||||
if (n == 1)
|
||||
{
|
||||
EaseHome(_slideSpots[0], _slideViews[0]);
|
||||
ApplySlidePositions();
|
||||
return;
|
||||
}
|
||||
|
||||
const float collidePad = 14f;
|
||||
const float clearPad = 42f;
|
||||
EnsureSlideCapacity(n);
|
||||
for (int round = 0; round < 3; round++)
|
||||
{
|
||||
if (!MeasureSlideRects(cam)) return;
|
||||
System.Array.Clear(_slideDeltaT, 0, n);
|
||||
System.Array.Clear(_slideDeltaLift, 0, n);
|
||||
System.Array.Clear(_slideColliding, 0, n);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
for (int j = i + 1; j < n; j++)
|
||||
{
|
||||
if (!RectOverlap(_slideRects[i], _slideRects[j], collidePad, out float ox, out float oy))
|
||||
continue;
|
||||
_slideColliding[i] = true;
|
||||
_slideColliding[j] = true;
|
||||
PushPair(cam, i, j, Mathf.Min(ox, oy), _slideDeltaT, _slideDeltaLift);
|
||||
}
|
||||
}
|
||||
bool moved = false;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (!_slideColliding[i]) continue;
|
||||
float nextT = IndustryCatalog.ClampPathT(_slideSpots[i], _slideViews[i].TrackT + _slideDeltaT[i]);
|
||||
float nextL = Mathf.Clamp(_slideViews[i].HeightLift + _slideDeltaLift[i], 0f, 220f);
|
||||
if (Mathf.Abs(nextT - _slideViews[i].TrackT) > 0.00015f
|
||||
|| Mathf.Abs(nextL - _slideViews[i].HeightLift) > 0.05f)
|
||||
moved = true;
|
||||
_slideViews[i].TrackT = nextT;
|
||||
_slideViews[i].HeightLift = nextL;
|
||||
}
|
||||
if (!moved) break;
|
||||
ApplySlidePositions();
|
||||
}
|
||||
|
||||
if (!MeasureSlideRects(cam)) return;
|
||||
System.Array.Clear(_slideNear, 0, n);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
for (int j = i + 1; j < n; j++)
|
||||
{
|
||||
if (!RectOverlap(_slideRects[i], _slideRects[j], clearPad, out _, out _))
|
||||
continue;
|
||||
_slideNear[i] = true;
|
||||
_slideNear[j] = true;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (_slideNear[i]) continue;
|
||||
EaseHome(_slideSpots[i], _slideViews[i]);
|
||||
}
|
||||
ApplySlidePositions();
|
||||
}
|
||||
|
||||
void EnsureSlideCapacity(int n)
|
||||
{
|
||||
if (_slideDeltaT.Length >= n) return;
|
||||
int size = Mathf.NextPowerOfTwo(Mathf.Max(4, n));
|
||||
_slideDeltaT = new float[size];
|
||||
_slideDeltaLift = new float[size];
|
||||
_slideColliding = new bool[size];
|
||||
_slideNear = new bool[size];
|
||||
}
|
||||
|
||||
void PushPair(Camera cam, int i, int j, float pen, float[] dT, float[] dL)
|
||||
{
|
||||
TrackSpot spotI = _slideSpots[i];
|
||||
TrackSpot spotJ = _slideSpots[j];
|
||||
IndustryTagView viewI = _slideViews[i];
|
||||
IndustryTagView viewJ = _slideViews[j];
|
||||
float tI = viewI.TrackT;
|
||||
float tJ = viewJ.TrackT;
|
||||
float dist0 = Vector2.Distance(PathScreen(cam, spotI, tI), PathScreen(cam, spotJ, tJ));
|
||||
float step = Mathf.Clamp(PixelsToPathT(cam, spotI, tI, Mathf.Max(12f, pen * 0.4f)), 0.05f, 0.14f);
|
||||
|
||||
int bestSI = 0;
|
||||
int bestSJ = 0;
|
||||
float bestDist = dist0;
|
||||
for (int s = 0; s < 4; s++)
|
||||
{
|
||||
int si = s == 0 || s == 2 ? 1 : -1;
|
||||
int sj = s == 0 || s == 3 ? -1 : 1;
|
||||
float d = Vector2.Distance(
|
||||
PathScreen(cam, spotI, tI + si * step),
|
||||
PathScreen(cam, spotJ, tJ + sj * step));
|
||||
if (d <= bestDist + 0.75f) continue;
|
||||
bestDist = d;
|
||||
bestSI = si;
|
||||
bestSJ = sj;
|
||||
}
|
||||
|
||||
float gain = bestDist - dist0;
|
||||
bool useHeight = gain < 16f;
|
||||
float slideShare = gain < 5f ? 0f : (useHeight ? 0.18f : 1f);
|
||||
if (slideShare > 0f && (bestSI != 0 || bestSJ != 0))
|
||||
{
|
||||
dT[i] += bestSI * PixelsToPathT(cam, spotI, tI, pen * slideShare * 0.32f);
|
||||
dT[j] += bestSJ * PixelsToPathT(cam, spotJ, tJ, pen * slideShare * 0.32f);
|
||||
}
|
||||
|
||||
if (useHeight)
|
||||
{
|
||||
Vector3 worldI = viewI.GamePos.GameToWorld();
|
||||
Vector3 worldJ = viewJ.GamePos.GameToWorld();
|
||||
float distI = (cam.transform.position - worldI).sqrMagnitude;
|
||||
float distJ = (cam.transform.position - worldJ).sqrMagnitude;
|
||||
float lift = YOffsetForPixels(cam, distI <= distJ ? worldJ : worldI, pen * 0.42f);
|
||||
if (distI <= distJ)
|
||||
{
|
||||
dL[j] += lift;
|
||||
dL[i] -= lift * 0.4f;
|
||||
}
|
||||
else
|
||||
{
|
||||
dL[i] += lift;
|
||||
dL[j] -= lift * 0.4f;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
dL[i] -= viewI.HeightLift * 0.45f;
|
||||
dL[j] -= viewJ.HeightLift * 0.45f;
|
||||
}
|
||||
}
|
||||
|
||||
static Vector2 PathScreen(Camera cam, TrackSpot spot, float t)
|
||||
{
|
||||
t = IndustryCatalog.ClampPathT(spot, t);
|
||||
Vector3 s = cam.WorldToScreenPoint(IndustryCatalog.PointOnPath(spot, t).GameToWorld());
|
||||
return new Vector2(s.x, s.y);
|
||||
}
|
||||
|
||||
static void EaseHome(TrackSpot spot, IndustryTagView view)
|
||||
{
|
||||
float k = 1f - Mathf.Exp(-5.5f * Time.unscaledDeltaTime);
|
||||
float homeT = IndustryCatalog.ClampPathT(spot, 0.5f);
|
||||
view.TrackT = Mathf.Lerp(view.TrackT, homeT, k);
|
||||
if (Mathf.Abs(view.TrackT - homeT) < 0.003f)
|
||||
view.TrackT = homeT;
|
||||
view.HeightLift = Mathf.Lerp(view.HeightLift, 0f, k);
|
||||
if (view.HeightLift < 0.25f)
|
||||
view.HeightLift = 0f;
|
||||
}
|
||||
|
||||
bool MeasureSlideRects(Camera cam)
|
||||
{
|
||||
int n = _slideViews.Count;
|
||||
_slideRects.Clear();
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (!_slideViews[i].TryScreenRect(cam, out Rect rect))
|
||||
return false;
|
||||
_slideRects.Add(rect);
|
||||
}
|
||||
return _slideRects.Count == n;
|
||||
}
|
||||
|
||||
static bool RectOverlap(Rect a, Rect b, float pad, out float ox, out float oy)
|
||||
{
|
||||
ox = Mathf.Min(a.xMax + pad, b.xMax + pad) - Mathf.Max(a.xMin - pad, b.xMin - pad);
|
||||
oy = Mathf.Min(a.yMax + pad, b.yMax + pad) - Mathf.Max(a.yMin - pad, b.yMin - pad);
|
||||
return ox > 0f && oy > 0f;
|
||||
}
|
||||
|
||||
void ApplySlidePositions()
|
||||
{
|
||||
for (int i = 0; i < _slideSpots.Count; i++)
|
||||
{
|
||||
Vector3 game = IndustryCatalog.PointOnPath(_slideSpots[i], _slideViews[i].TrackT);
|
||||
_slideViews[i].GamePos = game;
|
||||
_slideViews[i].SetWorld(game.GameToWorld());
|
||||
}
|
||||
}
|
||||
|
||||
static float YOffsetForPixels(Camera cam, Vector3 world, float pixels)
|
||||
{
|
||||
Vector3 a = cam.WorldToScreenPoint(world);
|
||||
Vector3 b = cam.WorldToScreenPoint(world + Vector3.up);
|
||||
if (a.z <= 0f || b.z <= 0f)
|
||||
return Mathf.Clamp(pixels * 0.2f, 8f, 80f);
|
||||
float py = Mathf.Abs(b.y - a.y);
|
||||
if (py < 0.5f) py = 0.5f;
|
||||
return Mathf.Clamp(pixels / py, 8f, 90f);
|
||||
}
|
||||
|
||||
static float PixelsToPathT(Camera cam, TrackSpot spot, float t, float pixels)
|
||||
{
|
||||
float sample = spot.PathLength > 1f ? Mathf.Clamp(4f / spot.PathLength, 0.015f, 0.08f) : 0.04f;
|
||||
Vector3 a = IndustryCatalog.PointOnPath(spot, Mathf.Clamp01(t - sample)).GameToWorld();
|
||||
Vector3 b = IndustryCatalog.PointOnPath(spot, Mathf.Clamp01(t + sample)).GameToWorld();
|
||||
Vector3 sa = cam.WorldToScreenPoint(a);
|
||||
Vector3 sb = cam.WorldToScreenPoint(b);
|
||||
float px = Vector2.Distance(new Vector2(sa.x, sa.y), new Vector2(sb.x, sb.y));
|
||||
float perT = px / (sample * 2f);
|
||||
if (perT < 8f) perT = 8f;
|
||||
return Mathf.Clamp(pixels / perT, 0.004f, 0.18f);
|
||||
}
|
||||
|
||||
static void FillSpanIds(IndustryTagView view, List<IndustryComponent> components)
|
||||
{
|
||||
view.SpanIds.Clear();
|
||||
foreach (IndustryComponent ic in components)
|
||||
{
|
||||
if (ic?.trackSpans == null) continue;
|
||||
foreach (TrackSpan span in ic.trackSpans)
|
||||
{
|
||||
if (span == null || string.IsNullOrEmpty(span.id)) continue;
|
||||
if (!view.SpanIds.Contains(span.id))
|
||||
view.SpanIds.Add(span.id);
|
||||
}
|
||||
}
|
||||
view.HighlightKey = string.Join("|", view.SpanIds);
|
||||
}
|
||||
|
||||
void TickPointer(Camera? cam)
|
||||
{
|
||||
PointerOver = false;
|
||||
if (cam == null)
|
||||
{
|
||||
SetHighlight(null);
|
||||
return;
|
||||
}
|
||||
Vector3 mouse = Input.mousePosition;
|
||||
IndustryTagView? best = null;
|
||||
float bestD = 99999f;
|
||||
foreach (IndustryTagView? view in EnumerateViews())
|
||||
{
|
||||
if (view == null || !view.gameObject.activeSelf) continue;
|
||||
if (!view.TryScreenHit(cam, mouse, out float d)) continue;
|
||||
if (d >= bestD) continue;
|
||||
bestD = d;
|
||||
best = view;
|
||||
}
|
||||
PointerOver = best != null;
|
||||
SetHighlight(best);
|
||||
if (best == null || !Input.GetMouseButtonDown(0)) return;
|
||||
float now = Time.unscaledTime;
|
||||
if (_clickView == best && now - _clickAt <= 0.4f)
|
||||
{
|
||||
best.JumpCamera();
|
||||
_clickView = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_clickView = best;
|
||||
_clickAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
IEnumerable<IndustryTagView> EnumerateViews()
|
||||
{
|
||||
foreach (var kv in _views)
|
||||
if (kv.Value != null) yield return kv.Value;
|
||||
foreach (var kv in _trackViews)
|
||||
if (kv.Value != null) yield return kv.Value;
|
||||
}
|
||||
|
||||
void SetHighlight(IndustryTagView? view)
|
||||
{
|
||||
string key = "";
|
||||
if (view != null)
|
||||
key = view.HighlightKey;
|
||||
if (key == _highlightKey) return;
|
||||
ClearHighlight();
|
||||
_highlightKey = key;
|
||||
if (key.Length == 0 || view == null) return;
|
||||
try
|
||||
{
|
||||
var ctrl = SegmentIndicatorController.Shared;
|
||||
if (ctrl != null)
|
||||
_highlightToken = ctrl.Add(view.SpanIds);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
void ClearHighlight()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_highlightToken))
|
||||
{
|
||||
try { SegmentIndicatorController.Shared?.Remove(_highlightToken); }
|
||||
catch { }
|
||||
}
|
||||
_highlightToken = null;
|
||||
_highlightKey = "";
|
||||
}
|
||||
|
||||
void ResetForPlaySession(object? session)
|
||||
{
|
||||
ClearHighlight();
|
||||
_playSession = session;
|
||||
foreach (var kv in _views)
|
||||
if (kv.Value != null) Destroy(kv.Value.gameObject);
|
||||
foreach (var kv in _trackViews)
|
||||
if (kv.Value != null) Destroy(kv.Value.gameObject);
|
||||
_views.Clear();
|
||||
_trackViews.Clear();
|
||||
_clusters.Clear();
|
||||
_tracks.Clear();
|
||||
_clustersInitialized = false;
|
||||
_tracksInitialized = false;
|
||||
_haveCatalogSettings = false;
|
||||
_haveSlideCameraPose = false;
|
||||
_lastSlideCount = -1;
|
||||
_collisionDirty = true;
|
||||
_rebuildAt = 0f;
|
||||
_trackRebuildAt = 0f;
|
||||
_refreshAt = 0f;
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
ClearHighlight();
|
||||
foreach (var kv in _views)
|
||||
if (kv.Value != null) Destroy(kv.Value.gameObject);
|
||||
_views.Clear();
|
||||
foreach (var kv in _trackViews)
|
||||
if (kv.Value != null) Destroy(kv.Value.gameObject);
|
||||
_trackViews.Clear();
|
||||
_clusters.Clear();
|
||||
_tracks.Clear();
|
||||
}
|
||||
|
||||
static bool WantVisible()
|
||||
{
|
||||
var s = IndustryTagsModule.Settings;
|
||||
if (s.alwaysOn) return true;
|
||||
if (!s.followTabTags) return false;
|
||||
try
|
||||
{
|
||||
TagController? tags = TagController.Shared;
|
||||
if (tags != null) return tags.TagsVisible;
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
void HideImmediate()
|
||||
{
|
||||
ClearHighlight();
|
||||
PointerOver = false;
|
||||
foreach (var kv in _views)
|
||||
kv.Value?.HideImmediate();
|
||||
foreach (var kv in _trackViews)
|
||||
kv.Value?.HideImmediate();
|
||||
}
|
||||
|
||||
void FadeAll(Camera? cam)
|
||||
{
|
||||
foreach (var kv in _views)
|
||||
kv.Value?.SetWanted(false);
|
||||
foreach (var kv in _trackViews)
|
||||
kv.Value?.SetWanted(false);
|
||||
foreach (var kv in _views)
|
||||
if (kv.Value != null && kv.Value.gameObject.activeSelf)
|
||||
kv.Value.TickAppearance(cam, true);
|
||||
foreach (var kv in _trackViews)
|
||||
if (kv.Value != null && kv.Value.gameObject.activeSelf)
|
||||
kv.Value.TickAppearance(cam, true);
|
||||
}
|
||||
|
||||
static Camera? PlayCamera()
|
||||
{
|
||||
Camera? cam = Camera.main;
|
||||
if (cam != null) return cam;
|
||||
try
|
||||
{
|
||||
Camera? found = null;
|
||||
if (MainCameraHelper.TryGetIfNeeded(ref found) && found != null)
|
||||
return found;
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool TryCameraGame(Camera? cam, out Vector3 gamePos)
|
||||
{
|
||||
gamePos = Vector3.zero;
|
||||
try
|
||||
{
|
||||
if (CameraSelector.shared != null)
|
||||
{
|
||||
gamePos = CameraSelector.shared.CurrentCameraPosition;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
if (cam == null) return false;
|
||||
try
|
||||
{
|
||||
gamePos = cam.transform.GamePosition();
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
static float DistMul(float dist, float maxDist)
|
||||
{
|
||||
if (maxDist < 1f) maxDist = 1f;
|
||||
if (dist >= maxDist) return 0f;
|
||||
float start = maxDist * 0.85f;
|
||||
if (dist <= start) return 1f;
|
||||
return Mathf.InverseLerp(maxDist, start, dist);
|
||||
}
|
||||
|
||||
static bool Nearly(float a, float b) => Mathf.Abs(a - b) <= 0.0001f;
|
||||
|
||||
static bool InPlay()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (TrainController.Shared == null) return false;
|
||||
if (SceneDescriptor.MainMenu.IsLoaded) return false;
|
||||
if (!SceneDescriptor.GameUI.IsLoaded) return false;
|
||||
if (LoadingScreenVisible()) return false;
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
static bool LoadingScreenVisible()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_loadingScreen != null)
|
||||
return _loadingScreen.activeInHierarchy;
|
||||
if (_loader == null)
|
||||
_loader = Object.FindObjectOfType<PersistentLoader>();
|
||||
if (_loader == null) return false;
|
||||
_loadingScreen = Traverse.Create(_loader).Field("loadingScreen").GetValue<GameObject>();
|
||||
return _loadingScreen != null && _loadingScreen.activeInHierarchy;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
1449
src/Modules/IndustryTags/IndustryTagView.cs
Normal file
1449
src/Modules/IndustryTags/IndustryTagView.cs
Normal file
File diff suppressed because it is too large
Load diff
575
src/Modules/IndustryTags/IndustryTagsDumpCommand.cs
Normal file
575
src/Modules/IndustryTags/IndustryTagsDumpCommand.cs
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using HarmonyLib;
|
||||
using Model;
|
||||
using Model.Ops;
|
||||
using S3.Core;
|
||||
using Track;
|
||||
using UI.Console;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.IndustryTags;
|
||||
|
||||
[HarmonyPatch(typeof(ConsoleCommandHandler))]
|
||||
[HarmonyPatch("_HandleSlashCommand")]
|
||||
static class IndustryTagsDumpCommandPatch
|
||||
{
|
||||
static bool Prefix(string[] comps, ref string __result)
|
||||
{
|
||||
if (comps.Length == 0 || !string.Equals(comps[0], "/s3ind", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
__result = IndustryTagsDumpCommand.Handle(comps);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static class IndustryTagsDumpCommand
|
||||
{
|
||||
internal static string Handle(string[] comps)
|
||||
{
|
||||
if (comps.Length >= 2)
|
||||
{
|
||||
string sub = comps[1].ToLowerInvariant();
|
||||
if (sub == "help") return Usage();
|
||||
if (sub == "yards") return DumpYards();
|
||||
if (sub != "dump") return $"Unknown subcommand '{comps[1]}'. {Usage()}";
|
||||
}
|
||||
|
||||
string filter = "";
|
||||
if (comps.Length >= 3)
|
||||
filter = string.Join(" ", comps.Skip(2)).Trim();
|
||||
return Dump(filter);
|
||||
}
|
||||
|
||||
static string Usage() =>
|
||||
"Usage: /s3ind dump [name] or /s3ind yards (writes Mods/S3/*.txt)";
|
||||
|
||||
static string DumpYards()
|
||||
{
|
||||
if (TrainController.Shared == null)
|
||||
return "Not in a game.";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"=== S3 yard dump {DateTime.Now:yyyy-MM-dd HH:mm:ss} ===");
|
||||
sb.AppendLine();
|
||||
|
||||
TrackSegment[]? all = null;
|
||||
try { all = UnityEngine.Object.FindObjectsOfType<TrackSegment>(); }
|
||||
catch (Exception e)
|
||||
{
|
||||
return Finish(sb.AppendLine("FindObjectsOfType failed: " + e.Message), "yard-dump.txt");
|
||||
}
|
||||
|
||||
int yardN = 0;
|
||||
if (all != null)
|
||||
{
|
||||
sb.AppendLine("-- Style.Yard segments --");
|
||||
foreach (TrackSegment seg in all.OrderBy(s => s != null ? s.id : "", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (seg == null || seg.style != TrackSegment.Style.Yard) continue;
|
||||
yardN++;
|
||||
float len = 0f;
|
||||
try { len = seg.GetLength(); } catch { }
|
||||
string goName = "";
|
||||
try { goName = seg.gameObject != null ? seg.gameObject.name : ""; } catch { }
|
||||
string parent = "";
|
||||
try
|
||||
{
|
||||
parent = seg.transform != null && seg.transform.parent != null
|
||||
? seg.transform.parent.name
|
||||
: "";
|
||||
}
|
||||
catch { }
|
||||
sb.AppendLine(
|
||||
$" {seg.id} name={goName} parent={parent} group={seg.groupId} " +
|
||||
$"len={len:F1}m/{len * 3.28084f:F0}ft avail={seg.Available} groupOn={seg.GroupEnabled}");
|
||||
}
|
||||
sb.AppendLine($"({yardN} yard segments)");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
var spots = new List<TrackSpot>();
|
||||
try { IndustryCatalog.RebuildTracks(spots); }
|
||||
catch (Exception e) { sb.AppendLine("RebuildTracks: " + e.Message); }
|
||||
int industrySpots = spots.Count;
|
||||
try { IndustryCatalog.RebuildYards(spots); }
|
||||
catch (Exception e) { sb.AppendLine("RebuildYards: " + e.Message); }
|
||||
|
||||
int tagged = 0;
|
||||
sb.AppendLine($"-- clustered yard tags (industry spots excluded={industrySpots}) --");
|
||||
foreach (TrackSpot spot in spots)
|
||||
{
|
||||
if (!spot.Yard) continue;
|
||||
tagged++;
|
||||
int cars = Mathf.Max(0, Mathf.FloorToInt(spot.PathLength / 15.24f));
|
||||
int ft = Mathf.Max(0, Mathf.RoundToInt(spot.PathLength * 3.28084f));
|
||||
string area = "";
|
||||
try { area = spot.Area != null ? spot.Area.name : ""; } catch { }
|
||||
sb.AppendLine(
|
||||
$" {spot.Label} {ft}ft {cars} cars area={area} pathPts={spot.Path.Count} key={spot.Key}");
|
||||
}
|
||||
sb.AppendLine($"({tagged} yard tags with a BY-style code)");
|
||||
if (yardN > 0 && tagged == 0)
|
||||
sb.AppendLine("No labels matched letter+digit codes (BY1). Check segment names above.");
|
||||
|
||||
return Finish(sb, "yard-dump.txt");
|
||||
}
|
||||
|
||||
static string Dump(string filter)
|
||||
{
|
||||
var ops = OpsController.Shared;
|
||||
var tc = TrainController.Shared;
|
||||
if (ops == null || tc == null)
|
||||
return "Not in a game.";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"=== S3 industry dump {DateTime.Now:yyyy-MM-dd HH:mm:ss} ===");
|
||||
if (!string.IsNullOrEmpty(filter))
|
||||
sb.AppendLine($"filter: {filter}");
|
||||
sb.AppendLine();
|
||||
|
||||
Car? selected = null;
|
||||
try { selected = tc.SelectedCar; } catch { }
|
||||
if (selected != null)
|
||||
DumpCar(sb, ops, selected, "SELECTED CAR");
|
||||
|
||||
Industry[]? industries = null;
|
||||
try { industries = ops.AllIndustries; } catch { }
|
||||
if (industries == null || industries.Length == 0)
|
||||
return Finish(sb.AppendLine("No industries."));
|
||||
|
||||
var componentToIndustry = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (Industry industry in industries)
|
||||
{
|
||||
if (industry == null) continue;
|
||||
try
|
||||
{
|
||||
foreach (IndustryComponent ic in industry.Components)
|
||||
{
|
||||
if (ic == null || string.IsNullOrEmpty(ic.Identifier)) continue;
|
||||
componentToIndustry[ic.Identifier] = industry.identifier;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
var inbound = new Dictionary<string, List<Car>>();
|
||||
var outbound = new Dictionary<string, List<Car>>();
|
||||
foreach (Car car in tc.Cars)
|
||||
{
|
||||
if (car == null) continue;
|
||||
Waybill? wb = car.Waybill;
|
||||
if (!wb.HasValue) continue;
|
||||
Waybill w = wb.Value;
|
||||
if (w.Completed) continue;
|
||||
string? destId = TryIndustryId(componentToIndustry, w.Destination.Identifier);
|
||||
string? originId = w.Origin.HasValue
|
||||
? TryIndustryId(componentToIndustry, w.Origin.Value.Identifier)
|
||||
: null;
|
||||
if (!string.IsNullOrEmpty(destId))
|
||||
Add(inbound, destId, car);
|
||||
if (!string.IsNullOrEmpty(originId) && originId != destId)
|
||||
Add(outbound, originId, car);
|
||||
}
|
||||
|
||||
sb.AppendLine("-- summary (inbound / CarsAtPosition / span.Contains / stopped / outbound) --");
|
||||
int dumped = 0;
|
||||
foreach (Industry industry in industries.OrderBy(i => i != null ? i.name : "", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (industry == null || string.IsNullOrEmpty(industry.identifier)) continue;
|
||||
if (!Matches(industry, filter)) continue;
|
||||
|
||||
List<Car> atPos = CarsAtPosition(ops, industry, stoppedOnly: false);
|
||||
List<Car> stopped = CarsAtPosition(ops, industry, stoppedOnly: true);
|
||||
int onSpan = 0;
|
||||
inbound.TryGetValue(industry.identifier, out var inCars);
|
||||
outbound.TryGetValue(industry.identifier, out var outCars);
|
||||
if (inCars != null)
|
||||
{
|
||||
foreach (Car car in inCars)
|
||||
{
|
||||
if (CarOnIndustrySpans(car, industry)) onSpan++;
|
||||
}
|
||||
}
|
||||
|
||||
bool interesting = string.IsNullOrEmpty(filter)
|
||||
|| (inCars != null && inCars.Count > 0)
|
||||
|| atPos.Count > 0
|
||||
|| (outCars != null && outCars.Count > 0)
|
||||
|| (selected != null && CarTouches(selected, industry, componentToIndustry));
|
||||
if (string.IsNullOrEmpty(filter) && !interesting)
|
||||
continue;
|
||||
|
||||
sb.AppendLine(
|
||||
$"{industry.name} id={industry.identifier} " +
|
||||
$"in={inCars?.Count ?? 0} atPos={atPos.Count} span={onSpan} stopped={stopped.Count} out={outCars?.Count ?? 0}");
|
||||
dumped++;
|
||||
}
|
||||
sb.AppendLine($"({dumped} industries listed)");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (Industry industry in industries.OrderBy(i => i != null ? i.name : "", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (industry == null) continue;
|
||||
if (!Matches(industry, filter)) continue;
|
||||
if (string.IsNullOrEmpty(filter))
|
||||
{
|
||||
inbound.TryGetValue(industry.identifier, out var inCars0);
|
||||
outbound.TryGetValue(industry.identifier, out var outCars0);
|
||||
if ((inCars0 == null || inCars0.Count == 0) && (outCars0 == null || outCars0.Count == 0))
|
||||
continue;
|
||||
}
|
||||
|
||||
DumpIndustry(sb, ops, industry, componentToIndustry,
|
||||
inbound.TryGetValue(industry.identifier, out var inList) ? inList : null,
|
||||
outbound.TryGetValue(industry.identifier, out var outList) ? outList : null);
|
||||
}
|
||||
|
||||
return Finish(sb);
|
||||
}
|
||||
|
||||
static void DumpIndustry(
|
||||
StringBuilder sb,
|
||||
OpsController ops,
|
||||
Industry industry,
|
||||
Dictionary<string, string> componentToIndustry,
|
||||
List<Car>? inbound,
|
||||
List<Car>? outbound)
|
||||
{
|
||||
sb.AppendLine($"== {industry.name} ({industry.identifier}) ==");
|
||||
try
|
||||
{
|
||||
foreach (IndustryComponent ic in industry.Components)
|
||||
{
|
||||
if (ic == null) continue;
|
||||
DumpComponent(sb, ops, ic);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
sb.AppendLine($" components error: {e.Message}");
|
||||
}
|
||||
|
||||
var atPos = new HashSet<Car>(CarsAtPosition(ops, industry, stoppedOnly: false));
|
||||
if (inbound != null && inbound.Count > 0)
|
||||
{
|
||||
sb.AppendLine(" inbound waybills:");
|
||||
foreach (Car car in inbound)
|
||||
DumpCarLine(sb, ops, industry, car, atPos, dest: true);
|
||||
}
|
||||
if (outbound != null && outbound.Count > 0)
|
||||
{
|
||||
sb.AppendLine(" outbound waybills:");
|
||||
foreach (Car car in outbound)
|
||||
DumpCarLine(sb, ops, industry, car, atPos, dest: false);
|
||||
}
|
||||
|
||||
foreach (Car car in atPos)
|
||||
{
|
||||
if (inbound != null && inbound.Contains(car)) continue;
|
||||
sb.AppendLine($" extra atPos (no inbound dest): {CarLabel(car)} vel={Vel(car)} type={Safe(car.CarType)}");
|
||||
DumpWaybillShort(sb, car, componentToIndustry);
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
static void DumpComponent(StringBuilder sb, OpsController ops, IndustryComponent ic)
|
||||
{
|
||||
string type = ic.GetType().Name;
|
||||
int spanN = ic.trackSpans != null ? ic.trackSpans.Length : 0;
|
||||
string vis = "?";
|
||||
try { vis = ic.IsVisible ? "vis" : "hidden"; } catch { }
|
||||
string filter = "";
|
||||
try { filter = ic.carTypeFilter != null ? ic.carTypeFilter.ToString() : ""; } catch { }
|
||||
sb.AppendLine($" [{type}] {ic.DisplayName} id={ic.Identifier} {vis} spans={spanN} filter={filter}");
|
||||
|
||||
if (ic.trackSpans != null)
|
||||
{
|
||||
foreach (TrackSpan span in ic.trackSpans)
|
||||
{
|
||||
if (span == null)
|
||||
{
|
||||
sb.AppendLine(" span=null");
|
||||
continue;
|
||||
}
|
||||
string segs = "";
|
||||
try
|
||||
{
|
||||
var list = span.GetSegments();
|
||||
if (list != null)
|
||||
segs = string.Join(",", list.Select(s => s != null ? s.id : "?"));
|
||||
}
|
||||
catch { segs = "err"; }
|
||||
bool valid = false;
|
||||
try { valid = span.IsValid; } catch { }
|
||||
sb.AppendLine($" span {span.id} valid={valid} segs=[{segs}]");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var cars = new List<Car>();
|
||||
foreach (Car c in ops.CarsAtPosition(ic))
|
||||
if (c != null) cars.Add(c);
|
||||
sb.AppendLine($" CarsAtPosition={cars.Count}" +
|
||||
(cars.Count == 0 ? "" : " " + string.Join(", ", cars.Select(CarLabel))));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
sb.AppendLine($" CarsAtPosition error: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static void DumpCarLine(StringBuilder sb, OpsController ops, Industry industry, Car car, HashSet<Car> atPos, bool dest)
|
||||
{
|
||||
bool span = CarOnIndustrySpans(car, industry);
|
||||
bool pos = atPos.Contains(car);
|
||||
string destId = "";
|
||||
string originId = "";
|
||||
try
|
||||
{
|
||||
Waybill? wb = car.Waybill;
|
||||
if (wb.HasValue)
|
||||
{
|
||||
destId = wb.Value.Destination.Identifier;
|
||||
if (wb.Value.Origin.HasValue)
|
||||
originId = wb.Value.Origin.Value.Identifier;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
sb.AppendLine(
|
||||
$" {CarLabel(car)} type={Safe(car.CarType)} vel={Vel(car)} " +
|
||||
$"spanContains={span} atPos={pos} dest={destId} origin={originId}");
|
||||
DumpLocation(sb, car);
|
||||
|
||||
try
|
||||
{
|
||||
if (ops.TryGetDestinationInfo(car, out var destName, out var isAt, out _, out var destPos))
|
||||
sb.AppendLine($" TryGetDestinationInfo name={destName} isAt={isAt} destId={destPos.Identifier}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
sb.AppendLine($" TryGetDestinationInfo error: {e.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
OpsCarPosition? here = ops.PositionForCar(car);
|
||||
sb.AppendLine(here.HasValue
|
||||
? $" PositionForCar {here.Value.DisplayName}/{here.Value.Identifier}"
|
||||
: " PositionForCar null");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
sb.AppendLine($" PositionForCar error: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static void DumpCar(StringBuilder sb, OpsController ops, Car car, string heading)
|
||||
{
|
||||
sb.AppendLine($"-- {heading}: {CarLabel(car)} --");
|
||||
sb.AppendLine($" type={Safe(car.CarType)} vel={Vel(car)}");
|
||||
DumpWaybillShort(sb, car, null);
|
||||
DumpLocation(sb, car);
|
||||
try
|
||||
{
|
||||
if (ops.TryGetDestinationInfo(car, out var destName, out var isAt, out var destWorld, out var destPos))
|
||||
sb.AppendLine($" destInfo {destName} isAt={isAt} id={destPos.Identifier} world={destWorld}");
|
||||
}
|
||||
catch (Exception e) { sb.AppendLine($" destInfo error: {e.Message}"); }
|
||||
try
|
||||
{
|
||||
OpsCarPosition? here = ops.PositionForCar(car);
|
||||
sb.AppendLine(here.HasValue
|
||||
? $" PositionForCar {here.Value.DisplayName}/{here.Value.Identifier}"
|
||||
: " PositionForCar null");
|
||||
}
|
||||
catch (Exception e) { sb.AppendLine($" PositionForCar error: {e.Message}"); }
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
static void DumpWaybillShort(StringBuilder sb, Car car, Dictionary<string, string>? map)
|
||||
{
|
||||
try
|
||||
{
|
||||
Waybill? wb = car.Waybill;
|
||||
if (!wb.HasValue)
|
||||
{
|
||||
sb.AppendLine(" waybill=none");
|
||||
return;
|
||||
}
|
||||
Waybill w = wb.Value;
|
||||
string destInd = map != null ? TryIndustryId(map, w.Destination.Identifier) ?? "-" : "";
|
||||
string originInd = "";
|
||||
if (map != null && w.Origin.HasValue)
|
||||
originInd = TryIndustryId(map, w.Origin.Value.Identifier) ?? "-";
|
||||
sb.AppendLine(
|
||||
$" waybill completed={w.Completed} dest={w.Destination.Identifier} ({destInd}) " +
|
||||
$"origin={(w.Origin.HasValue ? w.Origin.Value.Identifier : "none")} ({originInd}) tag={w.Tag}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
sb.AppendLine($" waybill error: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static void DumpLocation(StringBuilder sb, Car car)
|
||||
{
|
||||
try
|
||||
{
|
||||
Location opsLoc = car.OpsLocation;
|
||||
Location a = car.LocationA;
|
||||
Location b = car.LocationB;
|
||||
sb.AppendLine($" loc ops={Loc(opsLoc)} A={Loc(a)} B={Loc(b)} world={car.transform.position}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
sb.AppendLine($" loc error: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static string Loc(Location loc)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (loc.segment == null) return "null";
|
||||
return $"{loc.segment.id}@{loc.distance:F1}";
|
||||
}
|
||||
catch { return "?"; }
|
||||
}
|
||||
|
||||
static List<Car> CarsAtPosition(OpsController ops, Industry industry, bool stoppedOnly)
|
||||
{
|
||||
var list = new List<Car>();
|
||||
var seen = new HashSet<Car>();
|
||||
try
|
||||
{
|
||||
foreach (IndustryComponent ic in industry.Components)
|
||||
{
|
||||
if (ic == null || ic.trackSpans == null || ic.trackSpans.Length == 0) continue;
|
||||
foreach (Car car in ops.CarsAtPosition(ic))
|
||||
{
|
||||
if (car == null || !seen.Add(car)) continue;
|
||||
if (stoppedOnly && Mathf.Abs(car.velocity) > 0.05f) continue;
|
||||
list.Add(car);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return list;
|
||||
}
|
||||
|
||||
static bool CarOnIndustrySpans(Car car, Industry industry)
|
||||
{
|
||||
try
|
||||
{
|
||||
Location opsLoc = car.OpsLocation;
|
||||
Location a = car.LocationA;
|
||||
Location b = car.LocationB;
|
||||
Vector3 world = car.transform.position;
|
||||
foreach (IndustryComponent ic in industry.Components)
|
||||
{
|
||||
if (ic?.trackSpans == null) continue;
|
||||
foreach (TrackSpan span in ic.trackSpans)
|
||||
{
|
||||
if (span == null) continue;
|
||||
try
|
||||
{
|
||||
if (!span.IsValid) continue;
|
||||
if (span.Contains(opsLoc) || span.Contains(a) || span.Contains(b))
|
||||
return true;
|
||||
if (span.Contains(world, 4f))
|
||||
return true;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool CarTouches(Car car, Industry industry, Dictionary<string, string> map)
|
||||
{
|
||||
try
|
||||
{
|
||||
Waybill? wb = car.Waybill;
|
||||
if (!wb.HasValue) return false;
|
||||
string? dest = TryIndustryId(map, wb.Value.Destination.Identifier);
|
||||
if (dest == industry.identifier) return true;
|
||||
if (wb.Value.Origin.HasValue)
|
||||
{
|
||||
string? origin = TryIndustryId(map, wb.Value.Origin.Value.Identifier);
|
||||
if (origin == industry.identifier) return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
static string? TryIndustryId(Dictionary<string, string> map, string? componentId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(componentId)) return null;
|
||||
return map.TryGetValue(componentId, out var id) ? id : null;
|
||||
}
|
||||
|
||||
static void Add(Dictionary<string, List<Car>> dict, string key, Car car)
|
||||
{
|
||||
if (!dict.TryGetValue(key, out var list))
|
||||
dict[key] = list = new List<Car>();
|
||||
list.Add(car);
|
||||
}
|
||||
|
||||
static bool Matches(Industry industry, string filter)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filter)) return true;
|
||||
try
|
||||
{
|
||||
if (industry.name != null && industry.name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
return true;
|
||||
if (industry.identifier != null && industry.identifier.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
return true;
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
static string CarLabel(Car car)
|
||||
{
|
||||
try { return $"{car.DisplayName} ({car.id})"; }
|
||||
catch { return car != null ? car.id : "?"; }
|
||||
}
|
||||
|
||||
static string Vel(Car car)
|
||||
{
|
||||
try { return car.velocity.ToString("F2"); }
|
||||
catch { return "?"; }
|
||||
}
|
||||
|
||||
static string Safe(string? s) => string.IsNullOrEmpty(s) ? "-" : s;
|
||||
|
||||
static string Finish(StringBuilder sb, string fileName = "industry-dump.txt")
|
||||
{
|
||||
string text = sb.ToString();
|
||||
try { Log.Info("[s3ind]\n" + text); }
|
||||
catch { }
|
||||
|
||||
string path = "(not written)";
|
||||
try
|
||||
{
|
||||
string dir = Main.ModEntry.Path;
|
||||
path = Path.Combine(dir, fileName);
|
||||
File.WriteAllText(path, text);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return text + $"\nFailed to write file: {e.Message}";
|
||||
}
|
||||
|
||||
return $"Wrote {path}\n(also in the S3 log)";
|
||||
}
|
||||
}
|
||||
93
src/Modules/IndustryTags/IndustryTagsModule.cs
Normal file
93
src/Modules/IndustryTags/IndustryTagsModule.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
using System;
|
||||
using HarmonyLib;
|
||||
using S3.Core;
|
||||
using UI;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.IndustryTags;
|
||||
|
||||
public sealed class IndustryTagsModule : IModule
|
||||
{
|
||||
private const string SettingsFile = "S3.industrytags.json";
|
||||
|
||||
public static IndustryTagsSettings Settings { get; private set; } = new();
|
||||
|
||||
private static Harmony? _harmony;
|
||||
private static GameObject? _hostGo;
|
||||
|
||||
public IndustryTagsModule()
|
||||
{
|
||||
Settings = SettingsStore.Load<IndustryTagsSettings>(SettingsFile);
|
||||
if (Settings.poseRev < 2)
|
||||
{
|
||||
Settings.heightOffset = 80f;
|
||||
Settings.tagScale = 0.4f;
|
||||
Settings.maxDrawDistance = 850f;
|
||||
if (Settings.opacity < 0.05f) Settings.opacity = 0.9f;
|
||||
Settings.poseRev = 2;
|
||||
SettingsStore.Save(SettingsFile, Settings);
|
||||
}
|
||||
if (Settings.poseRev < 3)
|
||||
{
|
||||
Settings.hideIndustryWhenTracksVisible = true;
|
||||
Settings.poseRev = 3;
|
||||
SettingsStore.Save(SettingsFile, Settings);
|
||||
}
|
||||
bool saveTracks = false;
|
||||
if (Settings.trackScale < 0.08f) { Settings.trackScale = 0.55f; saveTracks = true; }
|
||||
if (Settings.trackHeightOffset < 1f) { Settings.trackHeightOffset = 24f; saveTracks = true; }
|
||||
if (Settings.trackOpacity < 0.05f) { Settings.trackOpacity = 0.9f; saveTracks = true; }
|
||||
if (Settings.trackMaxDrawDistance < 20f) { Settings.trackMaxDrawDistance = 650f; saveTracks = true; }
|
||||
if (Settings.titleFontSize < 6f) { Settings.titleFontSize = 14f; saveTracks = true; }
|
||||
if (Settings.trackTitleFontSize < 6f) { Settings.trackTitleFontSize = 12f; saveTracks = true; }
|
||||
if (saveTracks) SettingsStore.Save(SettingsFile, Settings);
|
||||
}
|
||||
|
||||
public string Id => "industrytags";
|
||||
public string DisplayName => "Industry Tags";
|
||||
public string Description =>
|
||||
"In-world callouts for businesses, industry tracks, and yard numbers. " +
|
||||
"Built from live map data, so modded maps work.";
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get => Settings.enabled;
|
||||
set => Settings.enabled = value;
|
||||
}
|
||||
|
||||
public void OnEnable()
|
||||
{
|
||||
_harmony = new Harmony("S3.industrytags");
|
||||
try { _harmony.CreateClassProcessor(typeof(IndustryTagsMouseOverUiPatch)).Patch(); }
|
||||
catch (Exception e) { Log.Error($"[industrytags] patch failed: {e.Message}"); }
|
||||
try { _harmony.CreateClassProcessor(typeof(IndustryTagsDumpCommandPatch)).Patch(); }
|
||||
catch (Exception e) { Log.Error($"[industrytags] dump command failed: {e.Message}"); }
|
||||
|
||||
_hostGo = new GameObject("[S3] IndustryTagsHost");
|
||||
UnityEngine.Object.DontDestroyOnLoad(_hostGo);
|
||||
_hostGo.AddComponent<IndustryTagOverlay>();
|
||||
}
|
||||
|
||||
public void OnDisable()
|
||||
{
|
||||
_harmony?.UnpatchAll("S3.industrytags");
|
||||
_harmony = null;
|
||||
if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo);
|
||||
_hostGo = null;
|
||||
IndustryTagOverlay.PointerOver = false;
|
||||
}
|
||||
|
||||
public void SaveSettings() => Persist();
|
||||
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
|
||||
|
||||
public void DrawSettings() => IndustryTagsSettingsUI.Draw();
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(GameInput), nameof(GameInput.IsMouseOverUI))]
|
||||
static class IndustryTagsMouseOverUiPatch
|
||||
{
|
||||
static void Postfix(ref bool __result)
|
||||
{
|
||||
if (IndustryTagOverlay.PointerOver) __result = true;
|
||||
}
|
||||
}
|
||||
40
src/Modules/IndustryTags/IndustryTagsSettings.cs
Normal file
40
src/Modules/IndustryTags/IndustryTagsSettings.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using System;
|
||||
|
||||
namespace S3.Modules.IndustryTags;
|
||||
|
||||
[Serializable]
|
||||
public class IndustryTagsSettings
|
||||
{
|
||||
public bool enabled = false;
|
||||
|
||||
public bool followTabTags = true;
|
||||
public bool alwaysOn = false;
|
||||
|
||||
public float mergeDistance = 250f;
|
||||
public float maxDrawDistance = 850f;
|
||||
public float heightOffset = 80f;
|
||||
public float tagScale = 0.4f;
|
||||
public float opacity = 0.9f;
|
||||
public float titleFontSize = 14f;
|
||||
|
||||
public bool showTrackBadges = true;
|
||||
public bool hideIndustryWhenTracksVisible = true;
|
||||
public float trackHeightOffset = 24f;
|
||||
public float trackScale = 0.55f;
|
||||
public float trackOpacity = 0.9f;
|
||||
public float trackTitleFontSize = 12f;
|
||||
public float trackMaxDrawDistance = 650f;
|
||||
|
||||
public bool showYardTags = true;
|
||||
public bool showYardFeet = false;
|
||||
public bool showYardCarLengths = false;
|
||||
|
||||
// Bumped when pose defaults change so old JSON is retuned once.
|
||||
public int poseRev = 3;
|
||||
|
||||
public bool showNeeds = true;
|
||||
public bool showOutputs = true;
|
||||
public bool showPerformance = true;
|
||||
public bool showStallReason = true;
|
||||
public bool showCarCounts = true;
|
||||
}
|
||||
93
src/Modules/IndustryTags/IndustryTagsSettingsUI.cs
Normal file
93
src/Modules/IndustryTags/IndustryTagsSettingsUI.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.IndustryTags;
|
||||
|
||||
static class IndustryTagsSettingsUI
|
||||
{
|
||||
public static void Draw()
|
||||
{
|
||||
var s = IndustryTagsModule.Settings;
|
||||
bool changed = false;
|
||||
|
||||
GUILayout.BeginVertical();
|
||||
GUILayout.Label("<b>Industry Tags</b> - in-world labels for businesses, tracks, and yards");
|
||||
GUILayout.Space(4f);
|
||||
GUILayout.Label(
|
||||
" Groups each company's tracks and puts one callout at the midpoint.\n" +
|
||||
" Reads live industry data, so modded maps work without extra setup.\n" +
|
||||
" Double-click a tag to center the strategy camera on that business.\n" +
|
||||
" Track badges sit over each loader/unloader; hover highlights that track.\n" +
|
||||
" Yard tags are smaller codes (BY1) on Style.Yard sidings, not industry spots.\n" +
|
||||
" Car counts: \u2192 still coming, loading-unloading / ready to pick up, rolling outbound \u2192.\n" +
|
||||
" Console: /s3ind dump [name] /s3ind yards",
|
||||
GUI.skin.label);
|
||||
|
||||
GUILayout.Space(10f);
|
||||
GUILayout.Label("<b>Visibility</b>");
|
||||
GUILayout.Space(4f);
|
||||
changed |= Toggle(ref s.followTabTags, " Show with car tags (Tab)");
|
||||
changed |= Toggle(ref s.alwaysOn, " Always show (ignores Tab)");
|
||||
|
||||
GUILayout.Space(10f);
|
||||
GUILayout.Label("<b>Contents</b>");
|
||||
GUILayout.Space(4f);
|
||||
changed |= Toggle(ref s.showNeeds, " Needs (inbound cargo and storage)");
|
||||
changed |= Toggle(ref s.showOutputs, " Making (outbound cargo and storage)");
|
||||
changed |= Toggle(ref s.showPerformance, " Contract performance");
|
||||
changed |= Toggle(ref s.showStallReason, " Stall reason (Needs steel, no contract, etc.)");
|
||||
changed |= Toggle(ref s.showCarCounts, " Car counts (\u2192 inbound loading/ready outbound \u2192)");
|
||||
|
||||
GUILayout.Space(10f);
|
||||
GUILayout.Label("<b>Placement</b>");
|
||||
GUILayout.Space(4f);
|
||||
changed |= Slider("Height", ref s.heightOffset, 10f, 200f, "0");
|
||||
changed |= Slider("Size", ref s.tagScale, 0.1f, 1.5f, "0.00");
|
||||
changed |= Slider("Title size", ref s.titleFontSize, 8f, 28f, "0");
|
||||
changed |= Slider("Opacity", ref s.opacity, 0.15f, 1f, "0.00");
|
||||
changed |= Slider("Merge distance", ref s.mergeDistance, 50f, 800f, "0");
|
||||
changed |= Slider("Draw distance", ref s.maxDrawDistance, 100f, 2000f, "0");
|
||||
|
||||
GUILayout.Space(10f);
|
||||
GUILayout.Label("<b>Track badges</b>");
|
||||
GUILayout.Space(4f);
|
||||
changed |= Toggle(ref s.showTrackBadges, " Show a smaller badge over each industry track");
|
||||
changed |= Toggle(ref s.hideIndustryWhenTracksVisible, " Hide the business tag when you are close enough to see track badges");
|
||||
changed |= Slider("Track height", ref s.trackHeightOffset, 5f, 80f, "0");
|
||||
changed |= Slider("Track size", ref s.trackScale, 0.1f, 1.5f, "0.00");
|
||||
changed |= Slider("Track title size", ref s.trackTitleFontSize, 8f, 28f, "0");
|
||||
changed |= Slider("Track opacity", ref s.trackOpacity, 0.15f, 1f, "0.00");
|
||||
changed |= Slider("Track draw distance", ref s.trackMaxDrawDistance, 80f, 2000f, "0");
|
||||
|
||||
GUILayout.Space(10f);
|
||||
GUILayout.Label("<b>Yard tags</b>");
|
||||
GUILayout.Space(4f);
|
||||
changed |= Toggle(ref s.showYardTags, " Show compact codes on yard tracks (BY1)");
|
||||
changed |= Toggle(ref s.showYardFeet, " Show how many feet fit");
|
||||
changed |= Toggle(ref s.showYardCarLengths, " Show how many 50 ft cars fit");
|
||||
|
||||
GUILayout.EndVertical();
|
||||
|
||||
if (changed)
|
||||
IndustryTagsModule.Persist();
|
||||
}
|
||||
|
||||
static bool Toggle(ref bool field, string label)
|
||||
{
|
||||
bool next = GUILayout.Toggle(field, label);
|
||||
if (next == field) return false;
|
||||
field = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool Slider(string label, ref float field, float min, float max, string fmt)
|
||||
{
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label(label, GUILayout.Width(120f));
|
||||
float nv = GUILayout.HorizontalSlider(field, min, max, GUILayout.Width(180f));
|
||||
GUILayout.Label(field.ToString(fmt), GUILayout.Width(48f));
|
||||
GUILayout.EndHorizontal();
|
||||
if (Mathf.Abs(nv - field) <= 0.01f) return false;
|
||||
field = nv;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
51
src/Modules/Mcp/McpConsole.cs
Normal file
51
src/Modules/Mcp/McpConsole.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
using System;
|
||||
using HarmonyLib;
|
||||
using S3.Core;
|
||||
using UI.Console;
|
||||
|
||||
namespace S3.Modules.Mcp;
|
||||
|
||||
[HarmonyPatch(typeof(ConsoleCommandHandler))]
|
||||
[HarmonyPatch("_HandleSlashCommand")]
|
||||
static class McpConsolePatch
|
||||
{
|
||||
static bool Prefix(string[] comps, ref string __result)
|
||||
{
|
||||
if (comps.Length == 0 || !string.Equals(comps[0], "/s3mcp", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
__result = McpConsole.Handle(comps);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static class McpConsole
|
||||
{
|
||||
internal static string Handle(string[] comps)
|
||||
{
|
||||
string sub = comps.Length >= 2 ? comps[1].ToLowerInvariant() : "status";
|
||||
var host = McpHost.Instance;
|
||||
var s = McpModule.Settings;
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "help":
|
||||
return "Usage: /s3mcp [status|token|reload|restart|help]";
|
||||
case "token":
|
||||
return s.token;
|
||||
case "reload":
|
||||
if (host == null) return "MCP host not running (enable the module and restart).";
|
||||
return host.ReloadNow(restartServer: true);
|
||||
case "restart":
|
||||
if (host == null) return "MCP host not running.";
|
||||
host.RequestRestart();
|
||||
return "restart queued";
|
||||
default:
|
||||
if (host == null)
|
||||
return "MCP host not running (enable MCP in S3 settings and restart).";
|
||||
return host.ListenSummary + "\n" + host.Url + "\ntoken=" + s.token +
|
||||
"\nplugins=" + host.PluginSummary +
|
||||
"\nobserve=" + s.observe + " control=" + s.control + " develop=" + s.develop +
|
||||
"\nautoReload=" + s.autoReload;
|
||||
}
|
||||
}
|
||||
}
|
||||
286
src/Modules/Mcp/McpGame.cs
Normal file
286
src/Modules/Mcp/McpGame.cs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Game;
|
||||
using HarmonyLib;
|
||||
using Helpers;
|
||||
using Model;
|
||||
using Model.Physics;
|
||||
using S3.Core;
|
||||
using S3.Mcp;
|
||||
using S3.Modules.PhysicsOptimizer;
|
||||
using UI.Console;
|
||||
using UI.Menu;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.Mcp;
|
||||
|
||||
sealed class McpGame : IMcpGame
|
||||
{
|
||||
static PersistentLoader? _loader;
|
||||
static GameObject? _loadingScreen;
|
||||
|
||||
public bool InPlay => ComputeInPlay();
|
||||
|
||||
public string StatusText()
|
||||
{
|
||||
var s = McpModule.Settings;
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("s3-mcp");
|
||||
sb.AppendLine($"inPlay={InPlay}");
|
||||
sb.AppendLine($"observe={s.observe} control={s.control} develop={s.develop}");
|
||||
sb.AppendLine($"autoReload={s.autoReload}");
|
||||
var host = McpHost.Instance;
|
||||
if (host != null)
|
||||
{
|
||||
sb.AppendLine($"listen={host.ListenSummary}");
|
||||
sb.AppendLine($"url={host.Url}");
|
||||
sb.AppendLine($"plugins={host.PluginSummary}");
|
||||
}
|
||||
try
|
||||
{
|
||||
var car = TrainController.Shared?.SelectedCar;
|
||||
if (car != null)
|
||||
sb.AppendLine($"selected={car.DisplayName} id={car.id}");
|
||||
else
|
||||
sb.AppendLine("selected=(none)");
|
||||
}
|
||||
catch { sb.AppendLine("selected=(error)"); }
|
||||
|
||||
Camera? cam = Camera.main;
|
||||
try { MainCameraHelper.TryGetIfNeeded(ref cam); }
|
||||
catch { }
|
||||
if (cam != null)
|
||||
{
|
||||
Vector3 p = cam.transform.position;
|
||||
sb.AppendLine($"cam={cam.name} pos=({p.x:0.0},{p.y:0.0},{p.z:0.0}) fov={cam.fieldOfView:0.0}");
|
||||
}
|
||||
else sb.AppendLine("cam=(none)");
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
public string ModulesText()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var reg = Main.Registry;
|
||||
if (reg == null)
|
||||
return "no registry";
|
||||
foreach (IModule m in reg.Modules)
|
||||
sb.AppendLine(
|
||||
$"{m.Id,-16} active={reg.IsActive(m),-5} configured={m.Enabled,-5} {m.DisplayName}");
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
public string LogTail(int lines) => string.Join("\n", Log.Tail(lines));
|
||||
|
||||
public string ConsistsText(int limit)
|
||||
{
|
||||
if (limit < 1) limit = 1;
|
||||
if (limit > 200) limit = 200;
|
||||
var tc = TrainController.Shared;
|
||||
if (tc == null) return "not in play";
|
||||
Car? selected = null;
|
||||
try { selected = tc.SelectedCar; }
|
||||
catch { }
|
||||
var seenSets = new HashSet<uint>();
|
||||
var seenLoose = new HashSet<string>();
|
||||
var sb = new StringBuilder();
|
||||
int n = 0;
|
||||
try
|
||||
{
|
||||
foreach (Car car in tc.Cars)
|
||||
{
|
||||
if (car == null) continue;
|
||||
IntegrationSet? set = car.set;
|
||||
if (set != null)
|
||||
{
|
||||
if (!seenSets.Add(set.Id)) continue;
|
||||
bool sel = false;
|
||||
try { sel = selected != null && selected.set != null && selected.set.Id == set.Id; }
|
||||
catch { }
|
||||
string lead = car.DisplayName;
|
||||
sb.AppendLine($"set #{set.Id} lead={lead} cars={set.NumberOfCars}{(sel ? " SELECTED" : "")}");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!seenLoose.Add(car.id)) continue;
|
||||
bool sel = selected != null && selected.id == car.id;
|
||||
sb.AppendLine($"{car.id} {car.DisplayName}{(sel ? " SELECTED" : "")}");
|
||||
}
|
||||
n++;
|
||||
if (n >= limit) break;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
sb.AppendLine("error: " + e.Message);
|
||||
}
|
||||
sb.AppendLine($"listed={n}");
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
public string SelectCar(string id)
|
||||
{
|
||||
var tc = TrainController.Shared;
|
||||
if (tc == null) return "not in play";
|
||||
if (string.IsNullOrEmpty(id)) return "id required";
|
||||
try
|
||||
{
|
||||
if (!tc.TryGetCarForId(id, out Car car) || car == null)
|
||||
return $"no car {id}";
|
||||
tc.SelectedCar = car;
|
||||
return $"selected {car.DisplayName} ({car.id})";
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return "select failed: " + e.Message;
|
||||
}
|
||||
}
|
||||
|
||||
public string FreezeSelected(bool freeze)
|
||||
{
|
||||
Car? car = null;
|
||||
try { car = TrainController.Shared?.SelectedCar; }
|
||||
catch { }
|
||||
if (car == null) return "no car selected";
|
||||
IntegrationSet? set = car.set;
|
||||
if (set == null) return $"{car.id} has no IntegrationSet";
|
||||
if (freeze)
|
||||
{
|
||||
set.SetVelocity(0f, set.Cars.ToList());
|
||||
bool added = ConsistFreezer.Freeze(set.Id);
|
||||
return added
|
||||
? $"froze set #{set.Id} ({set.NumberOfCars} cars)"
|
||||
: $"set #{set.Id} already frozen";
|
||||
}
|
||||
bool removed = ConsistFreezer.Unfreeze(set.Id);
|
||||
return removed
|
||||
? $"unfroze set #{set.Id}"
|
||||
: $"set #{set.Id} was not frozen";
|
||||
}
|
||||
|
||||
public string RunSlash(string command)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
return "empty command";
|
||||
command = command.Trim();
|
||||
if (!command.StartsWith("/"))
|
||||
command = "/" + command;
|
||||
|
||||
var handler = UnityEngine.Object.FindObjectOfType<ConsoleCommandHandler>();
|
||||
if (handler == null)
|
||||
return "ConsoleCommandHandler not found (open a session first).";
|
||||
|
||||
string[] comps;
|
||||
try
|
||||
{
|
||||
var tokenize = typeof(ConsoleCommandHandler).GetMethod(
|
||||
"Tokenize", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
if (tokenize == null)
|
||||
comps = command.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
else
|
||||
{
|
||||
var list = tokenize.Invoke(null, new object[] { command }) as List<string>;
|
||||
comps = list != null ? list.ToArray() : Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
comps = command.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
if (comps.Length == 0)
|
||||
return "empty command";
|
||||
|
||||
try
|
||||
{
|
||||
string? text = Traverse.Create(handler)
|
||||
.Method("_HandleSlashCommand", new[] { typeof(string[]) }, new object[] { comps })
|
||||
.GetValue<string>();
|
||||
return string.IsNullOrEmpty(text) ? "(no output)" : text;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return "slash failed: " + e.Message;
|
||||
}
|
||||
}
|
||||
|
||||
public string Screenshot(int maxWidth)
|
||||
{
|
||||
if (maxWidth < 160) maxWidth = 160;
|
||||
if (maxWidth > 2560) maxWidth = 2560;
|
||||
|
||||
Camera? cam = Camera.main;
|
||||
try { MainCameraHelper.TryGetIfNeeded(ref cam); }
|
||||
catch { }
|
||||
if (cam == null)
|
||||
return "no camera";
|
||||
|
||||
int w = Math.Max(160, cam.pixelWidth);
|
||||
int h = Math.Max(90, cam.pixelHeight);
|
||||
if (w > maxWidth)
|
||||
{
|
||||
float scale = maxWidth / (float)w;
|
||||
w = maxWidth;
|
||||
h = Math.Max(90, (int)(h * scale));
|
||||
}
|
||||
|
||||
RenderTexture? old = cam.targetTexture;
|
||||
var rt = RenderTexture.GetTemporary(w, h, 24);
|
||||
Texture2D? tex = null;
|
||||
try
|
||||
{
|
||||
cam.targetTexture = rt;
|
||||
cam.Render();
|
||||
RenderTexture.active = rt;
|
||||
tex = new Texture2D(w, h, TextureFormat.RGB24, false);
|
||||
tex.ReadPixels(new Rect(0, 0, w, h), 0, 0);
|
||||
tex.Apply();
|
||||
byte[] png = tex.EncodeToPNG();
|
||||
string path = Path.Combine(Main.ModEntry.Path, "mcp-shot.png");
|
||||
File.WriteAllBytes(path, png);
|
||||
return path;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return "screenshot failed: " + e.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
cam.targetTexture = old;
|
||||
RenderTexture.active = null;
|
||||
RenderTexture.ReleaseTemporary(rt);
|
||||
if (tex != null) UnityEngine.Object.Destroy(tex);
|
||||
}
|
||||
}
|
||||
|
||||
static bool ComputeInPlay()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (TrainController.Shared == null) return false;
|
||||
if (SceneDescriptor.MainMenu.IsLoaded) return false;
|
||||
if (!SceneDescriptor.GameUI.IsLoaded) return false;
|
||||
return !LoadingScreenVisible();
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
static bool LoadingScreenVisible()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_loadingScreen != null)
|
||||
return _loadingScreen.activeInHierarchy;
|
||||
if (_loader == null)
|
||||
_loader = UnityEngine.Object.FindObjectOfType<PersistentLoader>();
|
||||
if (_loader == null) return false;
|
||||
_loadingScreen = Traverse.Create(_loader).Field("loadingScreen").GetValue<GameObject>();
|
||||
return _loadingScreen != null && _loadingScreen.activeInHierarchy;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
362
src/Modules/Mcp/McpHost.cs
Normal file
362
src/Modules/Mcp/McpHost.cs
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using S3.Core;
|
||||
using S3.Mcp;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.Mcp;
|
||||
|
||||
sealed class McpWork
|
||||
{
|
||||
public JObject Request = null!;
|
||||
public JObject? Response;
|
||||
public ManualResetEventSlim Done = new(false);
|
||||
}
|
||||
|
||||
sealed class McpHost : MonoBehaviour
|
||||
{
|
||||
public static McpHost? Instance { get; private set; }
|
||||
|
||||
readonly ConcurrentQueue<McpWork> _queue = new();
|
||||
readonly McpRegistry _registry = new();
|
||||
readonly McpGame _game = new();
|
||||
|
||||
McpPlugins _plugins = null!;
|
||||
McpServer? _server;
|
||||
FileSystemWatcher? _watch;
|
||||
int _watchPulse;
|
||||
int _suppressWatchUntil;
|
||||
bool _reloadQueued;
|
||||
bool _restartQueued;
|
||||
string _lastError = "";
|
||||
|
||||
public string ListenSummary
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_server == null || !_server.Running)
|
||||
return "down" + (string.IsNullOrEmpty(_lastError) ? "" : " (" + _lastError + ")");
|
||||
return "127.0.0.1:" + _server.BoundPort;
|
||||
}
|
||||
}
|
||||
|
||||
public string Url =>
|
||||
_server != null && _server.Running
|
||||
? "http://127.0.0.1:" + _server.BoundPort + "/mcp"
|
||||
: "";
|
||||
|
||||
public string PluginSummary => _plugins != null ? _plugins.Summary : "(init)";
|
||||
|
||||
public string LastError => _lastError;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
_plugins = new McpPlugins(_registry, _game);
|
||||
RegisterHostTools();
|
||||
StartServer();
|
||||
ReloadTools(restartServer: false);
|
||||
StartWatcher();
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
StopWatcher();
|
||||
_plugins?.StopAll();
|
||||
_server?.Stop();
|
||||
while (_queue.TryDequeue(out var work))
|
||||
{
|
||||
work.Response = ProtocolError(work.Request, "host destroyed");
|
||||
work.Done.Set();
|
||||
}
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
DrainQueue();
|
||||
MaybeWatchReload();
|
||||
if (_reloadQueued)
|
||||
{
|
||||
_reloadQueued = false;
|
||||
bool restart = _restartQueued;
|
||||
_restartQueued = false;
|
||||
ReloadTools(restart);
|
||||
}
|
||||
else if (_restartQueued)
|
||||
{
|
||||
_restartQueued = false;
|
||||
StartServer();
|
||||
}
|
||||
}
|
||||
|
||||
void MaybeWatchReload()
|
||||
{
|
||||
if (_watchPulse == 0) return;
|
||||
int now = Environment.TickCount;
|
||||
if (now - _watchPulse < 800) return;
|
||||
_watchPulse = 0;
|
||||
if (!McpModule.Settings.autoReload) return;
|
||||
RequestReload(restartServer: true);
|
||||
}
|
||||
|
||||
public void RequestReload(bool restartServer)
|
||||
{
|
||||
_reloadQueued = true;
|
||||
if (restartServer) _restartQueued = true;
|
||||
}
|
||||
|
||||
public void RequestRestart() => _restartQueued = true;
|
||||
|
||||
public string ReloadNow(bool restartServer)
|
||||
{
|
||||
return ReloadTools(restartServer);
|
||||
}
|
||||
|
||||
JObject? DispatchOnMain(JObject request)
|
||||
{
|
||||
var work = new McpWork { Request = request };
|
||||
_queue.Enqueue(work);
|
||||
int timeout = Math.Max(1000, McpModule.Settings.callTimeoutMs);
|
||||
if (!work.Done.Wait(timeout))
|
||||
return ProtocolError(request, "timed out waiting for Unity main thread (" + timeout + "ms)");
|
||||
return work.Response;
|
||||
}
|
||||
|
||||
void DrainQueue()
|
||||
{
|
||||
int n = 0;
|
||||
while (n < 8 && _queue.TryDequeue(out var work))
|
||||
{
|
||||
n++;
|
||||
try { work.Response = McpProtocol.Handle(work.Request, _registry); }
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error("[mcp] dispatch: " + e);
|
||||
work.Response = ProtocolError(work.Request, e.Message);
|
||||
}
|
||||
work.Done.Set();
|
||||
}
|
||||
}
|
||||
|
||||
void StartServer()
|
||||
{
|
||||
_server?.Stop();
|
||||
var s = McpModule.Settings;
|
||||
_server = new McpServer(s.token ?? "", DispatchOnMain);
|
||||
if (!_server.Start(s.port))
|
||||
{
|
||||
_lastError = _server.LastError;
|
||||
return;
|
||||
}
|
||||
_lastError = "";
|
||||
WriteConnectFile();
|
||||
}
|
||||
|
||||
string ReloadTools(bool restartServer)
|
||||
{
|
||||
_suppressWatchUntil = Environment.TickCount + 1500;
|
||||
_watchPulse = 0;
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine(_plugins.ReloadOfficial());
|
||||
if (McpModule.Settings.develop)
|
||||
sb.AppendLine(_plugins.ReloadExtras());
|
||||
if (restartServer)
|
||||
StartServer();
|
||||
string text = sb.ToString().TrimEnd();
|
||||
Log.Info("[mcp] reload:\n" + text);
|
||||
return text;
|
||||
}
|
||||
|
||||
void StartWatcher()
|
||||
{
|
||||
StopWatcher();
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_plugins.PluginDir);
|
||||
_watch = new FileSystemWatcher(_plugins.PluginDir);
|
||||
_watch.Filter = "*.dll";
|
||||
_watch.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName | NotifyFilters.CreationTime;
|
||||
_watch.Changed += OnWatch;
|
||||
_watch.Created += OnWatch;
|
||||
_watch.Renamed += OnWatch;
|
||||
_watch.EnableRaisingEvents = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Warn("[mcp] watcher: " + e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
void StopWatcher()
|
||||
{
|
||||
if (_watch == null) return;
|
||||
try
|
||||
{
|
||||
_watch.EnableRaisingEvents = false;
|
||||
_watch.Changed -= OnWatch;
|
||||
_watch.Created -= OnWatch;
|
||||
_watch.Renamed -= OnWatch;
|
||||
_watch.Dispose();
|
||||
}
|
||||
catch { }
|
||||
_watch = null;
|
||||
}
|
||||
|
||||
void OnWatch(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
if (!McpModule.Settings.autoReload) return;
|
||||
int now = Environment.TickCount;
|
||||
if (now - _suppressWatchUntil < 0) return;
|
||||
string name = Path.GetFileName(e.Name ?? e.FullPath);
|
||||
bool official = string.Equals(name, McpPlugins.OfficialFileName, StringComparison.OrdinalIgnoreCase);
|
||||
bool extra = McpModule.Settings.develop && name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase);
|
||||
if (!official && !extra) return;
|
||||
_watchPulse = now;
|
||||
}
|
||||
|
||||
void WriteConnectFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
var obj = new JObject
|
||||
{
|
||||
["url"] = Url,
|
||||
["token"] = McpModule.Settings.token,
|
||||
["port"] = _server?.BoundPort ?? McpModule.Settings.port,
|
||||
["protocol"] = "mcp-streamable-http",
|
||||
};
|
||||
File.WriteAllText(
|
||||
Path.Combine(Main.ModEntry.Path, "mcp-connect.json"),
|
||||
obj.ToString(Formatting.Indented));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Warn("[mcp] connect file: " + e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
void RegisterHostTools()
|
||||
{
|
||||
AddHost("status",
|
||||
"Railroader S3 MCP host status: in-play, selected car, gates, listen URL, loaded plugins.",
|
||||
"{}",
|
||||
_ => McpToolResult.Ok(_game.StatusText()));
|
||||
|
||||
AddHost("mcp_reload",
|
||||
"Reload S3.Mcp.Tools.dll (and extra plugins if Develop is on). Restarts the HTTP listener by default so the MCP client re-fetches tools. Use this after dist/build-mcp-tools.ps1.",
|
||||
"{\"restart\":{\"type\":\"boolean\",\"description\":\"Bounce the HTTP server (default true).\"}}",
|
||||
args =>
|
||||
{
|
||||
bool restart = args["restart"] == null || args["restart"]!.Value<bool?>() != false;
|
||||
if (restart)
|
||||
{
|
||||
RequestReload(restartServer: true);
|
||||
return McpToolResult.Ok("reload+restart queued (listener will bounce after this call)");
|
||||
}
|
||||
return McpToolResult.Ok(ReloadNow(false));
|
||||
});
|
||||
|
||||
AddHost("mcp_restart",
|
||||
"Restart the localhost MCP HTTP listener without reloading plugin DLLs.",
|
||||
"{}",
|
||||
_ =>
|
||||
{
|
||||
RequestRestart();
|
||||
return McpToolResult.Ok("restart queued");
|
||||
});
|
||||
|
||||
AddHost("plugin_list",
|
||||
"List loaded MCP plugins and DLLs in Mods/S3/plugins.",
|
||||
"{}",
|
||||
_ =>
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("loaded: " + _plugins.Summary);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_plugins.PluginDir);
|
||||
foreach (string f in Directory.GetFiles(_plugins.PluginDir, "*.dll"))
|
||||
sb.AppendLine("file: " + Path.GetFileName(f));
|
||||
}
|
||||
catch (Exception e) { sb.AppendLine(e.Message); }
|
||||
return McpToolResult.Ok(sb.ToString().TrimEnd());
|
||||
});
|
||||
|
||||
AddHost("plugin_load",
|
||||
"Load or reload a plugin DLL from Mods/S3/plugins. Develop gate required for names other than S3.Mcp.Tools.dll.",
|
||||
"{\"name\":{\"type\":\"string\",\"description\":\"DLL file name. Omit to reload official tools.\"}}",
|
||||
args =>
|
||||
{
|
||||
string name = args["name"]?.Value<string>() ?? "";
|
||||
if (string.IsNullOrEmpty(name) ||
|
||||
string.Equals(name, McpPlugins.OfficialFileName, StringComparison.OrdinalIgnoreCase))
|
||||
return McpToolResult.Ok(ReloadNow(restartServer: false));
|
||||
if (!McpModule.Settings.develop)
|
||||
return McpToolResult.Fail("Develop gate is off");
|
||||
return McpToolResult.Ok(_plugins.LoadNamed(name));
|
||||
});
|
||||
|
||||
AddHost("plugin_stop",
|
||||
"Stop a loaded plugin by id and drop its tools.",
|
||||
"{\"id\":{\"type\":\"string\"}}",
|
||||
args => McpToolResult.Ok(_plugins.Stop(args["id"]?.Value<string>() ?? "")));
|
||||
|
||||
AddHost("console",
|
||||
"Run an in-game slash command on the Unity main thread (e.g. /s3ind dump, /s3wq dump, /rpf dump).",
|
||||
"{\"command\":{\"type\":\"string\"}}",
|
||||
args =>
|
||||
{
|
||||
string cmd = args["command"]?.Value<string>() ?? "";
|
||||
return McpToolResult.Ok(_game.RunSlash(cmd));
|
||||
});
|
||||
|
||||
AddHost("log_tail",
|
||||
"Last lines of the S3 log ring buffer.",
|
||||
"{\"lines\":{\"type\":\"integer\"}}",
|
||||
args =>
|
||||
{
|
||||
int lines = args["lines"]?.Value<int?>() ?? 80;
|
||||
return McpToolResult.Ok(_game.LogTail(lines));
|
||||
});
|
||||
|
||||
AddHost("modules",
|
||||
"S3 module id, enabled flag, and display name.",
|
||||
"{}",
|
||||
_ => McpToolResult.Ok(_game.ModulesText()));
|
||||
}
|
||||
|
||||
void AddHost(string name, string description, string propsJson, Func<JObject, McpToolResult> handler)
|
||||
{
|
||||
JObject props;
|
||||
try { props = JObject.Parse("{\"type\":\"object\",\"properties\":" + propsJson + ",\"additionalProperties\":false}"); }
|
||||
catch { props = JObject.Parse("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}"); }
|
||||
if (propsJson == "{}")
|
||||
props = JObject.Parse("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}");
|
||||
_registry.Add(new McpToolEntry
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
InputSchema = props,
|
||||
Gate = McpGate.Observe,
|
||||
Source = "host",
|
||||
Handler = handler,
|
||||
});
|
||||
}
|
||||
|
||||
static JObject ProtocolError(JObject request, string message)
|
||||
{
|
||||
var obj = new JObject
|
||||
{
|
||||
["jsonrpc"] = "2.0",
|
||||
["error"] = new JObject { ["code"] = -32603, ["message"] = message },
|
||||
};
|
||||
if (request["id"] != null && request["id"]!.Type != JTokenType.Null)
|
||||
obj["id"] = request["id"];
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
66
src/Modules/Mcp/McpModule.cs
Normal file
66
src/Modules/Mcp/McpModule.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
using System;
|
||||
using HarmonyLib;
|
||||
using S3.Core;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.Mcp;
|
||||
|
||||
public sealed class McpModule : IModule
|
||||
{
|
||||
private const string SettingsFile = "S3.mcp.json";
|
||||
|
||||
public static McpSettings Settings { get; private set; } = new();
|
||||
|
||||
private static Harmony? _harmony;
|
||||
private static GameObject? _hostGo;
|
||||
|
||||
public McpModule()
|
||||
{
|
||||
Settings = SettingsStore.Load<McpSettings>(SettingsFile);
|
||||
if (string.IsNullOrEmpty(Settings.token))
|
||||
{
|
||||
Settings.token = "s3-local-dev";
|
||||
SettingsStore.Save(SettingsFile, Settings);
|
||||
}
|
||||
if (Settings.port <= 0 || Settings.port >= 65536)
|
||||
Settings.port = 18765;
|
||||
if (Settings.callTimeoutMs < 1000)
|
||||
Settings.callTimeoutMs = 8000;
|
||||
}
|
||||
|
||||
public string Id => "mcp";
|
||||
public string DisplayName => "MCP";
|
||||
public string Description =>
|
||||
"Localhost MCP server so a coding agent can inspect the live game and, with gates on, " +
|
||||
"control it or load plugin DLLs. Tools reload without quitting. Console: /s3mcp";
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get => Settings.enabled;
|
||||
set => Settings.enabled = value;
|
||||
}
|
||||
|
||||
public void OnEnable()
|
||||
{
|
||||
_harmony = new Harmony("S3.mcp");
|
||||
try { _harmony.CreateClassProcessor(typeof(McpConsolePatch)).Patch(); }
|
||||
catch (Exception e) { Log.Error($"[mcp] console patch failed: {e.Message}"); }
|
||||
|
||||
_hostGo = new GameObject("[S3] McpHost");
|
||||
UnityEngine.Object.DontDestroyOnLoad(_hostGo);
|
||||
_hostGo.AddComponent<McpHost>();
|
||||
}
|
||||
|
||||
public void OnDisable()
|
||||
{
|
||||
_harmony?.UnpatchAll("S3.mcp");
|
||||
_harmony = null;
|
||||
if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo);
|
||||
_hostGo = null;
|
||||
}
|
||||
|
||||
public void SaveSettings() => Persist();
|
||||
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
|
||||
|
||||
public void DrawSettings() => McpSettingsUI.Draw();
|
||||
}
|
||||
243
src/Modules/Mcp/McpPlugins.cs
Normal file
243
src/Modules/Mcp/McpPlugins.cs
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using S3.Core;
|
||||
using S3.Mcp;
|
||||
|
||||
namespace S3.Modules.Mcp;
|
||||
|
||||
sealed class LoadedPlugin
|
||||
{
|
||||
public string Id = "";
|
||||
public string Path = "";
|
||||
public IAgentPlugin Instance = null!;
|
||||
}
|
||||
|
||||
sealed class McpPlugins
|
||||
{
|
||||
public const string OfficialFileName = "S3.Mcp.Tools.dll";
|
||||
|
||||
readonly McpRegistry _registry;
|
||||
readonly IMcpGame _game;
|
||||
readonly List<LoadedPlugin> _loaded = new();
|
||||
|
||||
public McpPlugins(McpRegistry registry, IMcpGame game)
|
||||
{
|
||||
_registry = registry;
|
||||
_game = game;
|
||||
}
|
||||
|
||||
public string PluginDir => Path.Combine(Main.ModEntry.Path, "plugins");
|
||||
|
||||
public string OfficialPath => Path.Combine(PluginDir, OfficialFileName);
|
||||
|
||||
public string Summary
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_loaded.Count == 0) return "(none)";
|
||||
var ids = new string[_loaded.Count];
|
||||
for (int i = 0; i < _loaded.Count; i++)
|
||||
ids[i] = _loaded[i].Id;
|
||||
return string.Join(", ", ids);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<LoadedPlugin> Loaded => _loaded;
|
||||
|
||||
public string ReloadOfficial()
|
||||
{
|
||||
UnloadById("s3.mcp.tools");
|
||||
UnloadById("tools");
|
||||
Directory.CreateDirectory(PluginDir);
|
||||
if (!File.Exists(OfficialPath))
|
||||
return "no " + OfficialFileName + " in " + PluginDir;
|
||||
return LoadFile(OfficialPath);
|
||||
}
|
||||
|
||||
public string ReloadExtras()
|
||||
{
|
||||
Directory.CreateDirectory(PluginDir);
|
||||
var sb = new System.Text.StringBuilder();
|
||||
foreach (string path in Directory.GetFiles(PluginDir, "*.dll"))
|
||||
{
|
||||
string name = Path.GetFileName(path);
|
||||
if (string.Equals(name, OfficialFileName, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
if (name.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
UnloadByFile(path);
|
||||
sb.AppendLine(LoadFile(path));
|
||||
}
|
||||
string text = sb.ToString().TrimEnd();
|
||||
return string.IsNullOrEmpty(text) ? "no extra plugins" : text;
|
||||
}
|
||||
|
||||
public string LoadNamed(string name)
|
||||
{
|
||||
Directory.CreateDirectory(PluginDir);
|
||||
string path = name;
|
||||
if (!Path.IsPathRooted(path))
|
||||
path = Path.Combine(PluginDir, name);
|
||||
if (!path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||
path += ".dll";
|
||||
if (!File.Exists(path))
|
||||
return "not found: " + path;
|
||||
UnloadByFile(path);
|
||||
return LoadFile(path);
|
||||
}
|
||||
|
||||
public string Stop(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
return "id required";
|
||||
if (!UnloadById(id))
|
||||
return "not loaded: " + id;
|
||||
return "stopped " + id;
|
||||
}
|
||||
|
||||
public void StopAll()
|
||||
{
|
||||
for (int i = _loaded.Count - 1; i >= 0; i--)
|
||||
StopOne(_loaded[i]);
|
||||
_loaded.Clear();
|
||||
}
|
||||
|
||||
string LoadFile(string path)
|
||||
{
|
||||
byte[] bytes;
|
||||
try { bytes = File.ReadAllBytes(path); }
|
||||
catch (Exception e) { return "read failed " + path + ": " + e.Message; }
|
||||
|
||||
Assembly asm;
|
||||
try { asm = Assembly.Load(bytes); }
|
||||
catch (Exception e) { return "Assembly.Load failed " + Path.GetFileName(path) + ": " + e.Message; }
|
||||
|
||||
Type? pluginType = null;
|
||||
try
|
||||
{
|
||||
foreach (Type t in asm.GetTypes())
|
||||
{
|
||||
if (t == null || t.IsAbstract || t.IsInterface) continue;
|
||||
if (typeof(IAgentPlugin).IsAssignableFrom(t))
|
||||
{
|
||||
pluginType = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (ReflectionTypeLoadException e)
|
||||
{
|
||||
return "types failed in " + Path.GetFileName(path) + ": " + (e.LoaderExceptions?.Length > 0 ? e.LoaderExceptions[0]?.Message : e.Message);
|
||||
}
|
||||
|
||||
if (pluginType == null)
|
||||
return Path.GetFileName(path) + " has no IAgentPlugin";
|
||||
|
||||
IAgentPlugin instance;
|
||||
try { instance = (IAgentPlugin)Activator.CreateInstance(pluginType)!; }
|
||||
catch (Exception e) { return "create failed: " + e.Message; }
|
||||
|
||||
string id = instance.Id;
|
||||
if (string.IsNullOrEmpty(id))
|
||||
id = pluginType.Name;
|
||||
UnloadById(id);
|
||||
|
||||
var api = new McpApi(_registry, _game, id);
|
||||
try { instance.Start(api); }
|
||||
catch (Exception e)
|
||||
{
|
||||
try { instance.Stop(); }
|
||||
catch { }
|
||||
return "Start failed (" + id + "): " + e.Message;
|
||||
}
|
||||
|
||||
_loaded.Add(new LoadedPlugin { Id = id, Path = path, Instance = instance });
|
||||
Log.Info("[mcp] loaded plugin " + id + " from " + Path.GetFileName(path));
|
||||
return "loaded " + id + " (" + Path.GetFileName(path) + ")";
|
||||
}
|
||||
|
||||
bool UnloadById(string id)
|
||||
{
|
||||
bool any = false;
|
||||
for (int i = _loaded.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!string.Equals(_loaded[i].Id, id, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
StopOne(_loaded[i]);
|
||||
_loaded.RemoveAt(i);
|
||||
any = true;
|
||||
}
|
||||
return any;
|
||||
}
|
||||
|
||||
void UnloadByFile(string path)
|
||||
{
|
||||
for (int i = _loaded.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!string.Equals(_loaded[i].Path, path, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
StopOne(_loaded[i]);
|
||||
_loaded.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
void StopOne(LoadedPlugin p)
|
||||
{
|
||||
_registry.ClearSource(p.Id);
|
||||
try { p.Instance.Stop(); }
|
||||
catch (Exception e) { Log.Warn("[mcp] Stop " + p.Id + ": " + e.Message); }
|
||||
}
|
||||
}
|
||||
|
||||
sealed class McpApi : IMcpApi
|
||||
{
|
||||
readonly McpRegistry _registry;
|
||||
readonly string _sourceId;
|
||||
readonly IMcpGame _game;
|
||||
|
||||
public McpApi(McpRegistry registry, IMcpGame game, string sourceId)
|
||||
{
|
||||
_registry = registry;
|
||||
_game = game;
|
||||
_sourceId = sourceId;
|
||||
}
|
||||
|
||||
public bool Observe => McpModule.Settings.observe;
|
||||
public bool Control => McpModule.Settings.control;
|
||||
public bool Develop => McpModule.Settings.develop;
|
||||
public IMcpGame Game => _game;
|
||||
|
||||
public void Log(string msg) => S3.Core.Log.Info("[mcp:" + _sourceId + "] " + msg);
|
||||
|
||||
public void RegisterTool(
|
||||
string name,
|
||||
string description,
|
||||
string inputSchemaJson,
|
||||
McpGate gate,
|
||||
Func<JObject, McpToolResult> handler)
|
||||
{
|
||||
JObject schema;
|
||||
try { schema = string.IsNullOrEmpty(inputSchemaJson) ? EmptySchema() : JObject.Parse(inputSchemaJson); }
|
||||
catch { schema = EmptySchema(); }
|
||||
|
||||
_registry.Add(new McpToolEntry
|
||||
{
|
||||
Name = name,
|
||||
Description = description ?? "",
|
||||
InputSchema = schema,
|
||||
Gate = gate,
|
||||
Source = _sourceId,
|
||||
Handler = handler,
|
||||
});
|
||||
}
|
||||
|
||||
static JObject EmptySchema() => new()
|
||||
{
|
||||
["type"] = "object",
|
||||
["properties"] = new JObject(),
|
||||
["additionalProperties"] = false,
|
||||
};
|
||||
}
|
||||
183
src/Modules/Mcp/McpProtocol.cs
Normal file
183
src/Modules/Mcp/McpProtocol.cs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using S3.Core;
|
||||
using S3.Mcp;
|
||||
|
||||
namespace S3.Modules.Mcp;
|
||||
|
||||
static class McpProtocol
|
||||
{
|
||||
public const string ServerName = "s3";
|
||||
|
||||
public static JObject? Handle(JObject req, McpRegistry registry)
|
||||
{
|
||||
string method = req["method"]?.Value<string>() ?? "";
|
||||
JToken? id = req["id"];
|
||||
bool notification = id == null || id.Type == JTokenType.Null;
|
||||
JObject args = req["params"] as JObject ?? new JObject();
|
||||
|
||||
try
|
||||
{
|
||||
if (method == "initialize")
|
||||
return Result(id, Initialize(args));
|
||||
if (method == "notifications/initialized" || method == "initialized")
|
||||
return notification ? null : Result(id, new JObject());
|
||||
if (method == "ping")
|
||||
return Result(id, new JObject());
|
||||
if (method == "tools/list")
|
||||
return Result(id, ToolsList(registry));
|
||||
if (method == "tools/call")
|
||||
return Result(id, ToolsCall(args, registry));
|
||||
|
||||
if (notification)
|
||||
return null;
|
||||
return Error(id, -32601, "unknown method " + method);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error("[mcp] " + method + ": " + e);
|
||||
if (notification) return null;
|
||||
return Error(id, -32603, e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
static JObject Initialize(JObject args)
|
||||
{
|
||||
string clientVer = args["protocolVersion"]?.Value<string>() ?? "2024-11-05";
|
||||
string ver = clientVer;
|
||||
if (ver != "2024-11-05" && ver != "2025-03-26" && ver != "2025-06-18")
|
||||
ver = "2024-11-05";
|
||||
|
||||
string version = "s3";
|
||||
try { version = Main.ModEntry.Version.ToString(); }
|
||||
catch { }
|
||||
|
||||
return new JObject
|
||||
{
|
||||
["protocolVersion"] = ver,
|
||||
["capabilities"] = new JObject
|
||||
{
|
||||
["tools"] = new JObject { ["listChanged"] = true },
|
||||
},
|
||||
["serverInfo"] = new JObject
|
||||
{
|
||||
["name"] = ServerName,
|
||||
["version"] = version,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static JObject ToolsList(McpRegistry registry)
|
||||
{
|
||||
var tools = new JArray();
|
||||
foreach (McpToolEntry t in registry.Visible())
|
||||
{
|
||||
tools.Add(new JObject
|
||||
{
|
||||
["name"] = t.Name,
|
||||
["description"] = t.Description,
|
||||
["inputSchema"] = t.InputSchema,
|
||||
});
|
||||
}
|
||||
return new JObject { ["tools"] = tools };
|
||||
}
|
||||
|
||||
static JObject ToolsCall(JObject args, McpRegistry registry)
|
||||
{
|
||||
string name = args["name"]?.Value<string>() ?? "";
|
||||
JObject callArgs = args["arguments"] as JObject ?? new JObject();
|
||||
var entry = registry.Find(name);
|
||||
if (entry == null)
|
||||
return CallError("unknown tool " + name);
|
||||
|
||||
var s = McpModule.Settings;
|
||||
if (entry.Source != "host")
|
||||
{
|
||||
if (entry.Gate == McpGate.Observe && !s.observe)
|
||||
return CallError("Observe gate is off");
|
||||
if (entry.Gate == McpGate.Control && !s.control)
|
||||
return CallError("Control gate is off");
|
||||
if (entry.Gate == McpGate.Develop && !s.develop)
|
||||
return CallError("Develop gate is off");
|
||||
}
|
||||
|
||||
McpToolResult result;
|
||||
try { result = entry.Handler(callArgs) ?? McpToolResult.Fail("null result"); }
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error("[mcp] tool " + name + ": " + e);
|
||||
result = McpToolResult.Fail(e.Message);
|
||||
}
|
||||
|
||||
var content = new JArray
|
||||
{
|
||||
new JObject { ["type"] = "text", ["text"] = result.Text ?? "" },
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(result.ImagePath) && File.Exists(result.ImagePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] bytes = File.ReadAllBytes(result.ImagePath);
|
||||
if (bytes.Length > 0 && bytes.Length < 1_800_000)
|
||||
{
|
||||
content.Add(new JObject
|
||||
{
|
||||
["type"] = "image",
|
||||
["mimeType"] = "image/png",
|
||||
["data"] = Convert.ToBase64String(bytes),
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
content.Add(new JObject
|
||||
{
|
||||
["type"] = "text",
|
||||
["text"] = "\n(image attach failed: " + e.Message + ")",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new JObject
|
||||
{
|
||||
["content"] = content,
|
||||
["isError"] = result.IsError,
|
||||
};
|
||||
}
|
||||
|
||||
static JObject CallError(string message) =>
|
||||
new()
|
||||
{
|
||||
["content"] = new JArray
|
||||
{
|
||||
new JObject { ["type"] = "text", ["text"] = message },
|
||||
},
|
||||
["isError"] = true,
|
||||
};
|
||||
|
||||
static JObject Result(JToken? id, JToken result)
|
||||
{
|
||||
var obj = new JObject
|
||||
{
|
||||
["jsonrpc"] = "2.0",
|
||||
["result"] = result,
|
||||
};
|
||||
if (id != null && id.Type != JTokenType.Null)
|
||||
obj["id"] = id;
|
||||
return obj;
|
||||
}
|
||||
|
||||
static JObject Error(JToken? id, int code, string message)
|
||||
{
|
||||
var obj = new JObject
|
||||
{
|
||||
["jsonrpc"] = "2.0",
|
||||
["error"] = new JObject { ["code"] = code, ["message"] = message },
|
||||
};
|
||||
if (id != null && id.Type != JTokenType.Null)
|
||||
obj["id"] = id;
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
63
src/Modules/Mcp/McpPublic.cs
Normal file
63
src/Modules/Mcp/McpPublic.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace S3.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Public contract compiled into S3.dll. Reloadable tool assemblies
|
||||
/// (S3.Mcp.Tools.dll and extra plugins) implement <see cref="IAgentPlugin"/>
|
||||
/// and talk to the host only through these types.
|
||||
/// </summary>
|
||||
public enum McpGate
|
||||
{
|
||||
Observe = 0,
|
||||
Control = 1,
|
||||
Develop = 2,
|
||||
}
|
||||
|
||||
public sealed class McpToolResult
|
||||
{
|
||||
public string Text = "";
|
||||
public string? ImagePath;
|
||||
public bool IsError;
|
||||
|
||||
public static McpToolResult Ok(string text) => new() { Text = text ?? "" };
|
||||
public static McpToolResult Fail(string text) => new() { Text = text ?? "error", IsError = true };
|
||||
public static McpToolResult Image(string path, string caption) =>
|
||||
new() { Text = caption ?? "", ImagePath = path };
|
||||
}
|
||||
|
||||
public interface IAgentPlugin
|
||||
{
|
||||
string Id { get; }
|
||||
void Start(IMcpApi api);
|
||||
void Stop();
|
||||
}
|
||||
|
||||
public interface IMcpApi
|
||||
{
|
||||
bool Observe { get; }
|
||||
bool Control { get; }
|
||||
bool Develop { get; }
|
||||
IMcpGame Game { get; }
|
||||
void Log(string msg);
|
||||
void RegisterTool(
|
||||
string name,
|
||||
string description,
|
||||
string inputSchemaJson,
|
||||
McpGate gate,
|
||||
Func<JObject, McpToolResult> handler);
|
||||
}
|
||||
|
||||
public interface IMcpGame
|
||||
{
|
||||
bool InPlay { get; }
|
||||
string StatusText();
|
||||
string RunSlash(string command);
|
||||
string Screenshot(int maxWidth);
|
||||
string LogTail(int lines);
|
||||
string ModulesText();
|
||||
string ConsistsText(int limit);
|
||||
string SelectCar(string id);
|
||||
string FreezeSelected(bool freeze);
|
||||
}
|
||||
62
src/Modules/Mcp/McpRegistry.cs
Normal file
62
src/Modules/Mcp/McpRegistry.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using S3.Mcp;
|
||||
|
||||
namespace S3.Modules.Mcp;
|
||||
|
||||
sealed class McpToolEntry
|
||||
{
|
||||
public string Name = "";
|
||||
public string Description = "";
|
||||
public JObject InputSchema = new();
|
||||
public McpGate Gate;
|
||||
public string Source = "host";
|
||||
public Func<JObject, McpToolResult> Handler = _ => McpToolResult.Fail("no handler");
|
||||
}
|
||||
|
||||
sealed class McpRegistry
|
||||
{
|
||||
readonly List<McpToolEntry> _tools = new();
|
||||
|
||||
public IReadOnlyList<McpToolEntry> All => _tools;
|
||||
|
||||
public void ClearSource(string source)
|
||||
{
|
||||
for (int i = _tools.Count - 1; i >= 0; i--)
|
||||
if (_tools[i].Source == source)
|
||||
_tools.RemoveAt(i);
|
||||
}
|
||||
|
||||
public void Add(McpToolEntry entry)
|
||||
{
|
||||
for (int i = _tools.Count - 1; i >= 0; i--)
|
||||
if (_tools[i].Name == entry.Name)
|
||||
_tools.RemoveAt(i);
|
||||
_tools.Add(entry);
|
||||
}
|
||||
|
||||
public McpToolEntry? Find(string name)
|
||||
{
|
||||
for (int i = 0; i < _tools.Count; i++)
|
||||
if (_tools[i].Name == name)
|
||||
return _tools[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
public List<McpToolEntry> Visible()
|
||||
{
|
||||
var s = McpModule.Settings;
|
||||
var list = new List<McpToolEntry>();
|
||||
for (int i = 0; i < _tools.Count; i++)
|
||||
{
|
||||
var t = _tools[i];
|
||||
if (t.Source == "host") { list.Add(t); continue; }
|
||||
if (t.Gate == McpGate.Observe && !s.observe) continue;
|
||||
if (t.Gate == McpGate.Control && !s.control) continue;
|
||||
if (t.Gate == McpGate.Develop && !s.develop) continue;
|
||||
list.Add(t);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
303
src/Modules/Mcp/McpServer.cs
Normal file
303
src/Modules/Mcp/McpServer.cs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using S3.Core;
|
||||
|
||||
namespace S3.Modules.Mcp;
|
||||
|
||||
sealed class McpServer
|
||||
{
|
||||
readonly Func<JObject, JObject?> _dispatch;
|
||||
readonly string _token;
|
||||
|
||||
TcpListener? _listener;
|
||||
Thread? _thread;
|
||||
volatile bool _run;
|
||||
|
||||
public int BoundPort { get; private set; }
|
||||
public string LastError { get; private set; } = "";
|
||||
public bool Running => _run;
|
||||
|
||||
public McpServer(string token, Func<JObject, JObject?> dispatch)
|
||||
{
|
||||
_token = token ?? "";
|
||||
_dispatch = dispatch;
|
||||
}
|
||||
|
||||
public bool Start(int preferredPort)
|
||||
{
|
||||
Stop();
|
||||
Exception? last = null;
|
||||
int port = preferredPort;
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, port + i);
|
||||
listener.Start();
|
||||
_listener = listener;
|
||||
BoundPort = port + i;
|
||||
_run = true;
|
||||
LastError = "";
|
||||
_thread = new Thread(AcceptLoop) { IsBackground = true, Name = "S3-MCP" };
|
||||
_thread.Start();
|
||||
Log.Info($"[mcp] listening on 127.0.0.1:{BoundPort}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
last = e;
|
||||
}
|
||||
}
|
||||
LastError = last?.Message ?? "bind failed";
|
||||
Log.Error("[mcp] bind failed: " + LastError);
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_run = false;
|
||||
try { _listener?.Stop(); }
|
||||
catch { }
|
||||
_listener = null;
|
||||
if (_thread != null && _thread.IsAlive)
|
||||
{
|
||||
if (!_thread.Join(500))
|
||||
try { _thread.Interrupt(); }
|
||||
catch { }
|
||||
}
|
||||
_thread = null;
|
||||
}
|
||||
|
||||
void AcceptLoop()
|
||||
{
|
||||
while (_run)
|
||||
{
|
||||
TcpClient? client = null;
|
||||
try
|
||||
{
|
||||
client = _listener?.AcceptTcpClient();
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
if (!_run) break;
|
||||
continue;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (_run) Log.Warn("[mcp] accept: " + e.Message);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (client == null) continue;
|
||||
TcpClient captured = client;
|
||||
ThreadPool.QueueUserWorkItem(_ => HandleClient(captured));
|
||||
}
|
||||
}
|
||||
|
||||
void HandleClient(TcpClient client)
|
||||
{
|
||||
try
|
||||
{
|
||||
client.NoDelay = true;
|
||||
client.ReceiveTimeout = 20000;
|
||||
client.SendTimeout = 20000;
|
||||
using (client)
|
||||
using (NetworkStream stream = client.GetStream())
|
||||
{
|
||||
for (int n = 0; n < 32 && _run; n++)
|
||||
{
|
||||
if (!TryReadHttp(stream, out string method, out string path, out var headers, out byte[] body))
|
||||
break;
|
||||
|
||||
string conn = Header(headers, "connection");
|
||||
bool close = conn.IndexOf("close", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
|
||||
HandleOne(stream, method, path, headers, body);
|
||||
|
||||
if (close) break;
|
||||
if (string.Equals(Header(headers, "http-version-close"), "1", StringComparison.Ordinal))
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (_run) Log.Warn("[mcp] client: " + e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
void HandleOne(NetworkStream stream, string method, string path, Dictionary<string, string> headers, byte[] body)
|
||||
{
|
||||
if (path != "/mcp" && path != "/health" && path != "/")
|
||||
{
|
||||
WriteHttp(stream, 404, "text/plain", Encoding.UTF8.GetBytes("not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (method == "GET")
|
||||
{
|
||||
WriteHttp(stream, 200, "text/plain", Encoding.UTF8.GetBytes("s3-mcp ok"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (method != "POST")
|
||||
{
|
||||
WriteHttp(stream, 405, "text/plain", Encoding.UTF8.GetBytes("POST only"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_token))
|
||||
{
|
||||
string auth = Header(headers, "authorization");
|
||||
string expect = "Bearer " + _token;
|
||||
if (!string.Equals(auth, expect, StringComparison.Ordinal))
|
||||
{
|
||||
WriteHttp(stream, 401, "text/plain", Encoding.UTF8.GetBytes("unauthorized"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
JObject req;
|
||||
try
|
||||
{
|
||||
req = JObject.Parse(Encoding.UTF8.GetString(body));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
WriteHttp(stream, 400, "application/json",
|
||||
Encoding.UTF8.GetBytes("{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32700,\"message\":\"" +
|
||||
Escape(e.Message) + "\"}}"));
|
||||
return;
|
||||
}
|
||||
|
||||
bool hasId = req["id"] != null && req["id"]!.Type != JTokenType.Null;
|
||||
JObject? resp = _dispatch(req);
|
||||
if (!hasId)
|
||||
{
|
||||
WriteHttp(stream, 202, "text/plain", Array.Empty<byte>());
|
||||
return;
|
||||
}
|
||||
if (resp == null)
|
||||
resp = new JObject { ["jsonrpc"] = "2.0", ["id"] = req["id"], ["result"] = new JObject() };
|
||||
byte[] json = Encoding.UTF8.GetBytes(resp.ToString(Newtonsoft.Json.Formatting.None));
|
||||
WriteHttp(stream, 200, "application/json", json);
|
||||
}
|
||||
|
||||
static string Escape(string s) => (s ?? "").Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||
|
||||
static string Header(Dictionary<string, string> headers, string name) =>
|
||||
headers.TryGetValue(name, out string v) ? v : "";
|
||||
|
||||
static void WriteHttp(NetworkStream stream, int status, string contentType, byte[] body)
|
||||
{
|
||||
string reason = status == 200 ? "OK" : status == 202 ? "Accepted" : status == 401 ? "Unauthorized"
|
||||
: status == 404 ? "Not Found" : status == 405 ? "Method Not Allowed" : "Error";
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("HTTP/1.1 ").Append(status).Append(' ').Append(reason).Append("\r\n");
|
||||
sb.Append("Content-Type: ").Append(contentType).Append("\r\n");
|
||||
sb.Append("Content-Length: ").Append(body.Length).Append("\r\n");
|
||||
sb.Append("Connection: keep-alive\r\n");
|
||||
sb.Append("\r\n");
|
||||
byte[] head = Encoding.ASCII.GetBytes(sb.ToString());
|
||||
stream.Write(head, 0, head.Length);
|
||||
if (body.Length > 0)
|
||||
stream.Write(body, 0, body.Length);
|
||||
stream.Flush();
|
||||
}
|
||||
|
||||
static bool TryReadHttp(
|
||||
NetworkStream stream,
|
||||
out string method,
|
||||
out string path,
|
||||
out Dictionary<string, string> headers,
|
||||
out byte[] body)
|
||||
{
|
||||
method = "";
|
||||
path = "";
|
||||
headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
body = Array.Empty<byte>();
|
||||
|
||||
var ms = new MemoryStream();
|
||||
var buf = new byte[1024];
|
||||
int headerEnd = -1;
|
||||
while (headerEnd < 0 && ms.Length < 64 * 1024)
|
||||
{
|
||||
int n;
|
||||
try { n = stream.Read(buf, 0, buf.Length); }
|
||||
catch { return false; }
|
||||
if (n <= 0) return false;
|
||||
ms.Write(buf, 0, n);
|
||||
byte[] soFar = ms.ToArray();
|
||||
headerEnd = IndexOfHeaderEnd(soFar, soFar.Length);
|
||||
}
|
||||
if (headerEnd < 0) return false;
|
||||
|
||||
byte[] all = ms.ToArray();
|
||||
string headerText = Encoding.ASCII.GetString(all, 0, headerEnd);
|
||||
string[] lines = headerText.Split(new[] { "\r\n" }, StringSplitOptions.None);
|
||||
if (lines.Length == 0) return false;
|
||||
string[] req = lines[0].Split(' ');
|
||||
if (req.Length < 2) return false;
|
||||
method = req[0].ToUpperInvariant();
|
||||
path = req[1];
|
||||
int q = path.IndexOf('?');
|
||||
if (q >= 0) path = path.Substring(0, q);
|
||||
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
{
|
||||
int colon = lines[i].IndexOf(':');
|
||||
if (colon <= 0) continue;
|
||||
string key = lines[i].Substring(0, colon).Trim();
|
||||
string val = lines[i].Substring(colon + 1).Trim();
|
||||
headers[key] = val;
|
||||
}
|
||||
|
||||
int extra = all.Length - (headerEnd + 4);
|
||||
int length = 0;
|
||||
if (headers.TryGetValue("Content-Length", out string cl))
|
||||
int.TryParse(cl, out length);
|
||||
if (length < 0) length = 0;
|
||||
if (length > 1_000_000) return false;
|
||||
|
||||
var bodyBuf = new MemoryStream();
|
||||
if (extra > 0)
|
||||
bodyBuf.Write(all, headerEnd + 4, extra);
|
||||
while (bodyBuf.Length < length)
|
||||
{
|
||||
int need = length - (int)bodyBuf.Length;
|
||||
int n;
|
||||
try { n = stream.Read(buf, 0, Math.Min(buf.Length, need)); }
|
||||
catch { return false; }
|
||||
if (n <= 0) return false;
|
||||
bodyBuf.Write(buf, 0, n);
|
||||
}
|
||||
body = bodyBuf.ToArray();
|
||||
if (body.Length > length && length > 0)
|
||||
{
|
||||
var trimmed = new byte[length];
|
||||
Buffer.BlockCopy(body, 0, trimmed, 0, length);
|
||||
body = trimmed;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static int IndexOfHeaderEnd(byte[] data, int len)
|
||||
{
|
||||
for (int i = 0; i <= len - 4; i++)
|
||||
{
|
||||
if (data[i] == 13 && data[i + 1] == 10 && data[i + 2] == 13 && data[i + 3] == 10)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
19
src/Modules/Mcp/McpSettings.cs
Normal file
19
src/Modules/Mcp/McpSettings.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using System;
|
||||
|
||||
namespace S3.Modules.Mcp;
|
||||
|
||||
[Serializable]
|
||||
public class McpSettings
|
||||
{
|
||||
public bool enabled = false;
|
||||
|
||||
public int port = 18765;
|
||||
public string token = "s3-local-dev";
|
||||
|
||||
public bool observe = true;
|
||||
public bool control = false;
|
||||
public bool develop = true;
|
||||
|
||||
public int callTimeoutMs = 8000;
|
||||
public bool autoReload = true;
|
||||
}
|
||||
80
src/Modules/Mcp/McpSettingsUI.cs
Normal file
80
src/Modules/Mcp/McpSettingsUI.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
using S3.Mcp;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.Mcp;
|
||||
|
||||
static class McpSettingsUI
|
||||
{
|
||||
public static void Draw()
|
||||
{
|
||||
var s = McpModule.Settings;
|
||||
bool changed = false;
|
||||
|
||||
GUILayout.BeginVertical();
|
||||
GUILayout.Label("<b>MCP</b> - localhost agent socket. Tools live in a reloadable DLL.");
|
||||
GUILayout.Space(4f);
|
||||
GUILayout.Label(
|
||||
" A local coding agent talks to this process over HTTP MCP on 127.0.0.1.\n" +
|
||||
" Rebuild tools while the game is running: dist\\build-mcp-tools.ps1\n" +
|
||||
" The host reloads S3.Mcp.Tools.dll automatically (or /s3mcp reload).\n" +
|
||||
" Changing the host itself (this module in S3.dll) still needs a game restart.",
|
||||
GUI.skin.label);
|
||||
|
||||
GUILayout.Space(8f);
|
||||
var host = McpHost.Instance;
|
||||
string listen = host != null ? host.ListenSummary : "(not running this launch)";
|
||||
GUILayout.Label("Status: " + listen);
|
||||
GUILayout.Label("URL: " + (host != null ? host.Url : "-"));
|
||||
GUILayout.Label("Token: " + s.token);
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("Reload tools", GUILayout.Width(140f)))
|
||||
host?.RequestReload(restartServer: true);
|
||||
if (GUILayout.Button("Restart server", GUILayout.Width(140f)))
|
||||
host?.RequestRestart();
|
||||
GUILayout.EndHorizontal();
|
||||
|
||||
GUILayout.Space(10f);
|
||||
GUILayout.Label("<b>Gates</b> (only from this panel, not via MCP)");
|
||||
changed |= Toggle(ref s.observe, " Observe - read state, dumps, hover, screenshot");
|
||||
changed |= Toggle(ref s.control, " Control - mutate the live save (freeze, select)");
|
||||
changed |= Toggle(ref s.develop, " Develop - extra plugin DLLs besides S3.Mcp.Tools");
|
||||
|
||||
GUILayout.Space(10f);
|
||||
GUILayout.Label("<b>Listen</b>");
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label("Port", GUILayout.Width(80f));
|
||||
string portStr = GUILayout.TextField(s.port.ToString(), GUILayout.Width(80f));
|
||||
if (int.TryParse(portStr, out int p) && p != s.port && p > 0 && p < 65536)
|
||||
{
|
||||
s.port = p;
|
||||
changed = true;
|
||||
}
|
||||
GUILayout.EndHorizontal();
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label("Token", GUILayout.Width(80f));
|
||||
string tok = GUILayout.TextField(s.token ?? "", GUILayout.Width(280f));
|
||||
if (tok != s.token)
|
||||
{
|
||||
s.token = tok;
|
||||
changed = true;
|
||||
}
|
||||
GUILayout.EndHorizontal();
|
||||
|
||||
changed |= Toggle(ref s.autoReload, " Auto-reload S3.Mcp.Tools.dll when the file changes");
|
||||
|
||||
GUILayout.EndVertical();
|
||||
|
||||
if (changed)
|
||||
McpModule.Persist();
|
||||
}
|
||||
|
||||
static bool Toggle(ref bool value, string label)
|
||||
{
|
||||
bool next = GUILayout.Toggle(value, label);
|
||||
if (next == value) return false;
|
||||
value = next;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
44
src/Modules/MiscTweaks/MiscTweaksModule.cs
Normal file
44
src/Modules/MiscTweaks/MiscTweaksModule.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
using S3.Core;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.MiscTweaks;
|
||||
|
||||
public sealed class MiscTweaksModule : IModule
|
||||
{
|
||||
const string SettingsFile = "S3.misctweaks.json";
|
||||
|
||||
public static MiscTweaksSettings Settings { get; private set; } = new();
|
||||
|
||||
static GameObject? _host;
|
||||
|
||||
public MiscTweaksModule() =>
|
||||
Settings = SettingsStore.Load<MiscTweaksSettings>(SettingsFile);
|
||||
|
||||
public string Id => "misctweaks";
|
||||
public string DisplayName => "Misc. Tweaks";
|
||||
public string Description =>
|
||||
"Small quality-of-life changes that do not belong to a larger module.";
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get => Settings.enabled;
|
||||
set => Settings.enabled = value;
|
||||
}
|
||||
|
||||
public void OnEnable()
|
||||
{
|
||||
_host = new GameObject("[S3] MiscTweaksHost");
|
||||
Object.DontDestroyOnLoad(_host);
|
||||
_host.AddComponent<RecentSaveAutoLoader>();
|
||||
}
|
||||
|
||||
public void OnDisable()
|
||||
{
|
||||
if (_host != null) Object.Destroy(_host);
|
||||
_host = null;
|
||||
}
|
||||
|
||||
public void SaveSettings() => Persist();
|
||||
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
|
||||
public void DrawSettings() => MiscTweaksSettingsUI.Draw();
|
||||
}
|
||||
11
src/Modules/MiscTweaks/MiscTweaksSettings.cs
Normal file
11
src/Modules/MiscTweaks/MiscTweaksSettings.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using System;
|
||||
|
||||
namespace S3.Modules.MiscTweaks;
|
||||
|
||||
[Serializable]
|
||||
public class MiscTweaksSettings
|
||||
{
|
||||
public bool enabled = false;
|
||||
public bool autoLoadMostRecent = false;
|
||||
public float autoLoadCountdownSeconds = 3f;
|
||||
}
|
||||
42
src/Modules/MiscTweaks/MiscTweaksSettingsUI.cs
Normal file
42
src/Modules/MiscTweaks/MiscTweaksSettingsUI.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.MiscTweaks;
|
||||
|
||||
static class MiscTweaksSettingsUI
|
||||
{
|
||||
public static void Draw()
|
||||
{
|
||||
MiscTweaksSettings settings = MiscTweaksModule.Settings;
|
||||
bool changed = false;
|
||||
|
||||
GUILayout.Label("<b>Recent save autoload</b>");
|
||||
GUILayout.Space(4f);
|
||||
bool enabled = GUILayout.Toggle(
|
||||
settings.autoLoadMostRecent,
|
||||
" Automatically load the most recently modified save from the main menu");
|
||||
if (enabled != settings.autoLoadMostRecent)
|
||||
{
|
||||
settings.autoLoadMostRecent = enabled;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label("Countdown (seconds)", GUILayout.Width(175f));
|
||||
float countdown = GUILayout.HorizontalSlider(
|
||||
settings.autoLoadCountdownSeconds, 1f, 15f, GUILayout.Width(180f));
|
||||
GUILayout.Label(settings.autoLoadCountdownSeconds.ToString("0"), GUILayout.Width(48f));
|
||||
GUILayout.EndHorizontal();
|
||||
countdown = Mathf.Round(countdown);
|
||||
if (Mathf.Abs(countdown - settings.autoLoadCountdownSeconds) >= 0.5f)
|
||||
{
|
||||
settings.autoLoadCountdownSeconds = countdown;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
GUILayout.Label(
|
||||
" Press any keyboard, mouse, or controller button during the countdown to cancel.");
|
||||
|
||||
if (changed)
|
||||
MiscTweaksModule.Persist();
|
||||
}
|
||||
}
|
||||
177
src/Modules/MiscTweaks/RecentSaveAutoLoader.cs
Normal file
177
src/Modules/MiscTweaks/RecentSaveAutoLoader.cs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
using System;
|
||||
using System.Reflection;
|
||||
using Game;
|
||||
using Game.Persistence;
|
||||
using Game.State;
|
||||
using HarmonyLib;
|
||||
using S3.Core;
|
||||
using UI.Menu;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.MiscTweaks;
|
||||
|
||||
sealed class RecentSaveAutoLoader : MonoBehaviour
|
||||
{
|
||||
static readonly MethodInfo? StartSingleplayer =
|
||||
AccessTools.Method(
|
||||
typeof(MenuManager),
|
||||
"StartGameSinglePlayer",
|
||||
new[] { typeof(GameSetup) });
|
||||
|
||||
bool _wasMainMenu;
|
||||
bool _attempted;
|
||||
bool _counting;
|
||||
bool _cancelled;
|
||||
float _loadAt;
|
||||
float _acceptCancelAt;
|
||||
string _saveName = "";
|
||||
DateTime _saveDate;
|
||||
string _status = "";
|
||||
|
||||
void Update()
|
||||
{
|
||||
bool mainMenu = false;
|
||||
try { mainMenu = SceneDescriptor.MainMenu.IsLoaded; }
|
||||
catch { }
|
||||
|
||||
if (!mainMenu)
|
||||
{
|
||||
_wasMainMenu = false;
|
||||
_attempted = false;
|
||||
_counting = false;
|
||||
_cancelled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_wasMainMenu)
|
||||
{
|
||||
_wasMainMenu = true;
|
||||
_attempted = false;
|
||||
_counting = false;
|
||||
_cancelled = false;
|
||||
_status = "";
|
||||
}
|
||||
|
||||
MiscTweaksSettings settings = MiscTweaksModule.Settings;
|
||||
if (!settings.autoLoadMostRecent || _attempted) return;
|
||||
if (!_counting)
|
||||
{
|
||||
TryStartCountdown(settings);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Time.unscaledTime >= _acceptCancelAt && AnyButtonDown())
|
||||
{
|
||||
_counting = false;
|
||||
_cancelled = true;
|
||||
_attempted = true;
|
||||
_status = "Recent-save autoload cancelled.";
|
||||
Log.Info("[misc] " + _status);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Time.unscaledTime < _loadAt) return;
|
||||
_counting = false;
|
||||
_attempted = true;
|
||||
LoadRecentSave();
|
||||
}
|
||||
|
||||
void TryStartCountdown(MiscTweaksSettings settings)
|
||||
{
|
||||
if (StartSingleplayer == null)
|
||||
{
|
||||
_attempted = true;
|
||||
_status = "Recent-save autoload unavailable: game launch method not found.";
|
||||
Log.Warn("[misc] " + _status);
|
||||
return;
|
||||
}
|
||||
if (FindObjectOfType<MenuManager>() == null)
|
||||
return;
|
||||
|
||||
var saves = WorldStore.FindSaveInfos();
|
||||
if (saves == null || saves.Count == 0)
|
||||
{
|
||||
_attempted = true;
|
||||
_status = "No saves found.";
|
||||
return;
|
||||
}
|
||||
|
||||
WorldStore.SaveInfo recent = saves[0];
|
||||
_saveName = recent.Name;
|
||||
_saveDate = recent.Date;
|
||||
float seconds = Mathf.Clamp(settings.autoLoadCountdownSeconds, 1f, 15f);
|
||||
_loadAt = Time.unscaledTime + seconds;
|
||||
_acceptCancelAt = Time.unscaledTime + 0.15f;
|
||||
_counting = true;
|
||||
_status = $"Loading {_saveName} in {seconds:0} seconds...";
|
||||
Log.Info($"[misc] {_status}");
|
||||
}
|
||||
|
||||
void LoadRecentSave()
|
||||
{
|
||||
MenuManager? manager = FindObjectOfType<MenuManager>();
|
||||
if (manager == null || StartSingleplayer == null)
|
||||
{
|
||||
_status = "Recent-save autoload cancelled: main menu is no longer ready.";
|
||||
Log.Warn("[misc] " + _status);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_status = "Loading " + _saveName + "...";
|
||||
Log.Info("[misc] " + _status);
|
||||
StartSingleplayer.Invoke(manager, new object[] { new GameSetup(_saveName) });
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_status = "Recent-save autoload failed: " + (e.InnerException?.Message ?? e.Message);
|
||||
Log.Error("[misc] " + _status);
|
||||
}
|
||||
}
|
||||
|
||||
static bool AnyButtonDown()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Input.anyKeyDown) return true;
|
||||
for (int i = 0; i < 7; i++)
|
||||
if (Input.GetMouseButtonDown(i)) return true;
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
if (!_counting || _cancelled) return;
|
||||
float remaining = Mathf.Max(0f, _loadAt - Time.unscaledTime);
|
||||
const float width = 520f;
|
||||
const float height = 104f;
|
||||
Rect box = new Rect(
|
||||
(Screen.width - width) * 0.5f,
|
||||
Mathf.Max(24f, Screen.height * 0.13f),
|
||||
width,
|
||||
height);
|
||||
GUI.Box(box, "");
|
||||
var title = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
fontSize = 20,
|
||||
fontStyle = FontStyle.Bold,
|
||||
};
|
||||
var detail = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
fontSize = 14,
|
||||
};
|
||||
GUI.Label(
|
||||
new Rect(box.x + 12f, box.y + 10f, box.width - 24f, 32f),
|
||||
$"Loading most recent save in {Mathf.CeilToInt(remaining)}...",
|
||||
title);
|
||||
GUI.Label(
|
||||
new Rect(box.x + 12f, box.y + 43f, box.width - 24f, 50f),
|
||||
$"{_saveName} ({_saveDate:g})\nPress any keyboard, mouse, or controller button to cancel",
|
||||
detail);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ public class PhysicsSettings
|
|||
// LOD fast-path tier
|
||||
public bool OptimizerEnabled = true;
|
||||
public float DistanceThreshold = 30f;
|
||||
public int ResyncInterval = 8;
|
||||
public int ResyncInterval = 4;
|
||||
|
||||
// Auto-freeze tier
|
||||
public bool AutoFreezeEnabled = true;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ namespace S3.Modules.PhysicsOptimizer;
|
|||
static class PhysicsSettingsUI
|
||||
{
|
||||
static readonly int[] ResyncOptions = { 1, 2, 4, 8, 16 };
|
||||
static readonly string[] ResyncLabels = { "1/1 (max quality)", "1/2", "1/4", "1/8 (default)", "1/16" };
|
||||
static readonly string[] ResyncLabels = { "1/1 (max quality)", "1/2", "1/4 (default)", "1/8", "1/16" };
|
||||
|
||||
static string _addInput = "";
|
||||
static string _toRemove = null; // deferred to avoid mutating list during enumeration
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using HarmonyLib;
|
||||
using S3.Core; // Log
|
||||
using UI.Map;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
namespace S3.Modules.Popout {
|
||||
|
||||
|
|
@ -80,6 +82,18 @@ namespace S3.Modules.Popout {
|
|||
Native.RRPOPOUT_SetPanDisablesFollow(_windowHandle, PopoutModule.Settings.panDisablesFollow);
|
||||
Native.RRPOPOUT_SetRightClickRecenter(_windowHandle, PopoutModule.Settings.rightClickRecenter);
|
||||
SeedIconCullingState(_windowHandle);
|
||||
try
|
||||
{
|
||||
TrackLabelService.SeedEnabled(_windowHandle);
|
||||
TrackLabelService.SeedStyle(_windowHandle);
|
||||
TrackLabelService.Reset();
|
||||
MapViewPresets.PushList(_windowHandle);
|
||||
MapWaypointSystem.SeedState(_windowHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"[popout] map chrome seed failed (popout still active): {ex}");
|
||||
}
|
||||
Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha);
|
||||
var bgc = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme);
|
||||
bgc.a *= PopoutModule.Settings.overlayMapBgAlpha;
|
||||
|
|
@ -105,6 +119,7 @@ namespace S3.Modules.Popout {
|
|||
|
||||
_mapCamera.orthographicSize = Mathf.Clamp(mapZoom, 100f, 10000f);
|
||||
if (mapRotation != 0f) ApplyMapRotation(mapRotation);
|
||||
MapCameraMemory.TryRestore(_mapCamera, ApplyMapRotation);
|
||||
if (syncRotation != 0) { _mapSyncPlayer = true; Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, true); }
|
||||
if (followPlayer != 0) { _followPlayer = true; Native.RRPOPOUT_SetFollowPlayer(_windowHandle, true); }
|
||||
|
||||
|
|
@ -133,6 +148,7 @@ namespace S3.Modules.Popout {
|
|||
|
||||
// --- Status bar ---
|
||||
UpdateStatusText();
|
||||
TrackLabelService.Push(_windowHandle, _mapCamera);
|
||||
|
||||
// --- Menu list refresh ---
|
||||
_listTimer += Time.deltaTime;
|
||||
|
|
@ -176,11 +192,16 @@ namespace S3.Modules.Popout {
|
|||
if (hotkeyNow && !_prevHotkeyDown) CloseRequested = true;
|
||||
_prevHotkeyDown = hotkeyNow;
|
||||
|
||||
// T key ("Jump to Mouse"): teleport the player to the last-known cursor position
|
||||
// on the map. We use GetAsyncKeyState so this fires even when the game window
|
||||
// doesn't have focus. Unity's Input System won't fire Teleport from the popout
|
||||
// window, so we poll here instead. VK_T = 0x54 (matches the game's default binding).
|
||||
bool teleportNow = IsDown(0x54);
|
||||
// "Jump to Mouse": teleport the player to the last-known cursor position on the
|
||||
// map. We use GetAsyncKeyState so this fires even when the game window doesn't
|
||||
// have focus. Unity's Input System won't fire Teleport from the popout window,
|
||||
// so we poll here instead, using the key + modifier actually bound to Game/Teleport
|
||||
// (resolved once via ResolveTeleportBinding) rather than a hardcoded key — a plain
|
||||
// VK_T poll ignored whatever modifier the binding requires (default Shift+T, or
|
||||
// any rebind), so the popout would fire on the bare key alone.
|
||||
ResolveTeleportBinding();
|
||||
bool teleportNow = IsDown(_teleportVk) &&
|
||||
(_teleportModifierVk == 0 || IsDown(_teleportModifierVk));
|
||||
if (teleportNow && !_prevTeleportDown && _lastMouseX >= 0f)
|
||||
PanelFinder.GetMapDrag()?.OnTeleport?.Invoke(new Vector2(_lastMouseX, 1f - _lastMouseY));
|
||||
_prevTeleportDown = teleportNow;
|
||||
|
|
@ -204,6 +225,19 @@ namespace S3.Modules.Popout {
|
|||
Native.RRPOPOUT_SetMapRotation(_windowHandle, _mapRotationDeg);
|
||||
}
|
||||
|
||||
private void DisableFollowForPreset() {
|
||||
if (_followPlayer) {
|
||||
_followPlayer = false;
|
||||
Native.RRPOPOUT_SetFollowPlayer(_windowHandle, false);
|
||||
}
|
||||
if (_mapSyncPlayer) {
|
||||
_mapSyncPlayer = false;
|
||||
Native.RRPOPOUT_SetMapSyncPlayer(_windowHandle, false);
|
||||
}
|
||||
if (MapEnhancerBridge.IsInstalled && MapEnhancerBridge.FollowMode)
|
||||
MapEnhancerBridge.ToggleFollowMode();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
private void UpdateStatusText() {
|
||||
string status = PanelFinder.BuildStatusText(_mapCamera!);
|
||||
|
|
@ -243,6 +277,59 @@ namespace S3.Modules.Popout {
|
|||
|
||||
private static bool IsDown(int vk) => (GetAsyncKeyState(vk) & 0x8000) != 0;
|
||||
|
||||
// Cache of the actual Game/Teleport keybind (resolved once, lazily, from the game's
|
||||
// live InputAction so a rebind is picked up on the next popout open). Falls back to
|
||||
// Shift+T — the game's own default — if resolution fails for any reason.
|
||||
private static bool _teleportBindingResolved;
|
||||
private static int _teleportVk = 0x54; // VK_T
|
||||
private static int _teleportModifierVk = 0x10; // VK_SHIFT
|
||||
|
||||
private static void ResolveTeleportBinding() {
|
||||
if (_teleportBindingResolved) return;
|
||||
_teleportBindingResolved = true;
|
||||
try {
|
||||
var action = Traverse.Create(UI.GameInput.shared).Field("_teleportAction").GetValue<InputAction>();
|
||||
if (action == null) return;
|
||||
|
||||
int mainVk = 0, modVk = 0;
|
||||
foreach (var binding in action.bindings) {
|
||||
int vk = KeyPathToVirtualKey(binding.effectivePath);
|
||||
if (vk == 0) continue;
|
||||
if (binding.isPartOfComposite && string.Equals(binding.name, "modifier", StringComparison.OrdinalIgnoreCase))
|
||||
modVk = vk;
|
||||
else if (!binding.isComposite)
|
||||
mainVk = vk;
|
||||
}
|
||||
if (mainVk != 0) {
|
||||
_teleportVk = mainVk;
|
||||
_teleportModifierVk = modVk;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Log.Error($"[popout] failed to resolve Teleport keybind, falling back to Shift+T: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
// Maps an Input System key path (e.g. "<Keyboard>/t", "<Keyboard>/leftShift",
|
||||
// "<Keyboard>/f5") to its Win32 virtual-key code. Returns 0 if unrecognised.
|
||||
private static int KeyPathToVirtualKey(string? effectivePath) {
|
||||
if (string.IsNullOrEmpty(effectivePath)) return 0;
|
||||
int slash = effectivePath!.LastIndexOf('/');
|
||||
string key = (slash >= 0 ? effectivePath.Substring(slash + 1) : effectivePath).ToLowerInvariant();
|
||||
switch (key) {
|
||||
case "leftshift": case "rightshift": case "shift": return 0x10;
|
||||
case "leftctrl": case "rightctrl": case "ctrl": return 0x11;
|
||||
case "leftalt": case "rightalt": case "alt": return 0x12;
|
||||
}
|
||||
if (key.Length == 1) {
|
||||
char c = key[0];
|
||||
if (c >= 'a' && c <= 'z') return char.ToUpperInvariant(c);
|
||||
if (c >= '0' && c <= '9') return c;
|
||||
}
|
||||
if (key.Length >= 2 && key[0] == 'f' && int.TryParse(key.Substring(1), out int fn) && fn is >= 1 and <= 24)
|
||||
return 0x70 + (fn - 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int ToVirtualKey(KeyCode k) {
|
||||
int c = (int)k;
|
||||
if (c >= 97 && c <= 122) return c - 32;
|
||||
|
|
@ -251,6 +338,8 @@ namespace S3.Modules.Popout {
|
|||
}
|
||||
|
||||
public void Destroy() {
|
||||
if (_mapCamera != null)
|
||||
MapCameraMemory.Capture(_mapCamera, _mapRotationDeg);
|
||||
if (_mapCamera != null) {
|
||||
_mapCamera.targetTexture = _savedTargetTexture;
|
||||
_mapCamera.rect = _savedRect;
|
||||
|
|
@ -330,7 +419,10 @@ namespace S3.Modules.Popout {
|
|||
|
||||
case InputEventType.LButtonUp:
|
||||
if (!_didDrag)
|
||||
PanelFinder.GetMapDrag()?.OnClick?.Invoke(viewportPos);
|
||||
{
|
||||
var vp = new Vector2(e.x, 1f - e.y);
|
||||
PanelFinder.GetMapDrag()?.OnClick?.Invoke(vp);
|
||||
}
|
||||
_isDragging = false;
|
||||
_didDrag = false;
|
||||
break;
|
||||
|
|
@ -494,6 +586,17 @@ namespace S3.Modules.Popout {
|
|||
PopoutModule.Settings.eotdSizeScale = Mathf.Clamp(e.y, 1.0f, 10.0f);
|
||||
PopoutModule.Persist();
|
||||
break;
|
||||
default:
|
||||
{
|
||||
var cmd = (UICmd)(int)e.x;
|
||||
if (TrackLabelService.TryHandleCommand(cmd, e.y, _windowHandle))
|
||||
break;
|
||||
if (MapViewPresets.TryHandleCommand(cmd, e.y, _windowHandle, _mapCamera,
|
||||
ApplyMapRotation, DisableFollowForPreset))
|
||||
break;
|
||||
MapWaypointSystem.TryHandleCommand(cmd, _windowHandle);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,6 +268,8 @@ namespace S3.Modules.Popout {
|
|||
.OrderBy(sp => sp.name)
|
||||
.ToArray();
|
||||
|
||||
public static void JumpToCar(Car car) => JumpToCarPosition(car);
|
||||
|
||||
private static void JumpToCarPosition(Car car) {
|
||||
try {
|
||||
var mapCam = MapBuilder.Shared?.mapCamera?.transform;
|
||||
|
|
|
|||
347
src/Modules/Popout/MapViewPresets.cs
Normal file
347
src/Modules/Popout/MapViewPresets.cs
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Helpers;
|
||||
using UI.Map;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.Popout;
|
||||
|
||||
internal struct MapViewPreset
|
||||
{
|
||||
public string name;
|
||||
public float x, z, zoom, rotationY;
|
||||
}
|
||||
|
||||
internal static class MapViewPresets
|
||||
{
|
||||
private static bool _stashValid;
|
||||
private static float _stashX, _stashZ, _stashZoom, _stashRot;
|
||||
|
||||
public static void PushList(int handle)
|
||||
{
|
||||
try
|
||||
{
|
||||
var presets = List();
|
||||
if (presets.Count == 0)
|
||||
{
|
||||
Native.RRPOPOUT_SetPresetList(handle, "");
|
||||
return;
|
||||
}
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < presets.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append('\n');
|
||||
sb.Append(string.IsNullOrEmpty(presets[i].name) ? $"View {i + 1}" : presets[i].name);
|
||||
}
|
||||
Native.RRPOPOUT_SetPresetList(handle, sb.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
S3.Core.Log.Error($"[popout] preset list push failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadRename(int handle)
|
||||
{
|
||||
try
|
||||
{
|
||||
var buf = new StringBuilder(128);
|
||||
Native.RRPOPOUT_GetPresetRenameName(handle, buf, 128);
|
||||
return buf.ToString().Trim();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
S3.Core.Log.Error($"[popout] preset rename read failed: {ex.Message}");
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryHandleCommand(UICmd cmd, float y, int handle, Camera? cam,
|
||||
Action<float> applyRotation, Action disableFollow)
|
||||
{
|
||||
int index = (int)y;
|
||||
switch (cmd)
|
||||
{
|
||||
case UICmd.PresetAdd:
|
||||
if (cam == null) return true;
|
||||
AddCurrent(cam);
|
||||
PushList(handle);
|
||||
return true;
|
||||
case UICmd.PresetApply:
|
||||
if (cam == null) return true;
|
||||
disableFollow();
|
||||
Apply(index, cam, applyRotation);
|
||||
return true;
|
||||
case UICmd.PresetDelete:
|
||||
Delete(index);
|
||||
CancelStash(cam, applyRotation);
|
||||
PushList(handle);
|
||||
return true;
|
||||
case UICmd.PresetRename:
|
||||
Rename(index, ReadRename(handle));
|
||||
PushList(handle);
|
||||
return true;
|
||||
case UICmd.PresetPreview:
|
||||
if (cam == null) return true;
|
||||
disableFollow();
|
||||
Preview(index, cam, applyRotation);
|
||||
return true;
|
||||
case UICmd.PresetCommitEdit:
|
||||
if (cam == null) return true;
|
||||
Rename(index, ReadRename(handle));
|
||||
CommitEdit(index, cam, applyRotation);
|
||||
PushList(handle);
|
||||
return true;
|
||||
case UICmd.PresetCancelEdit:
|
||||
CancelStash(cam, applyRotation);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<MapViewPreset> List()
|
||||
{
|
||||
var s = PopoutModule.Settings;
|
||||
var names = s.presetNames ?? Array.Empty<string>();
|
||||
var xs = s.presetX ?? Array.Empty<float>();
|
||||
var zs = s.presetZ ?? Array.Empty<float>();
|
||||
var zooms = s.presetZoom ?? Array.Empty<float>();
|
||||
var rots = s.presetRot ?? Array.Empty<float>();
|
||||
int n = Math.Min(names.Length, Math.Min(xs.Length, Math.Min(zs.Length, Math.Min(zooms.Length, rots.Length))));
|
||||
var list = new List<MapViewPreset>(n);
|
||||
for (int i = 0; i < n; i++)
|
||||
list.Add(new MapViewPreset
|
||||
{
|
||||
name = names[i] ?? $"View {i + 1}",
|
||||
x = xs[i], z = zs[i], zoom = zooms[i], rotationY = rots[i]
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
private static void Save(List<MapViewPreset> list)
|
||||
{
|
||||
int n = list.Count;
|
||||
var names = new string[n];
|
||||
var xs = new float[n];
|
||||
var zs = new float[n];
|
||||
var zooms = new float[n];
|
||||
var rots = new float[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
names[i] = list[i].name ?? "";
|
||||
xs[i] = list[i].x;
|
||||
zs[i] = list[i].z;
|
||||
zooms[i] = list[i].zoom;
|
||||
rots[i] = list[i].rotationY;
|
||||
}
|
||||
var s = PopoutModule.Settings;
|
||||
s.presetNames = names;
|
||||
s.presetX = xs;
|
||||
s.presetZ = zs;
|
||||
s.presetZoom = zooms;
|
||||
s.presetRot = rots;
|
||||
PopoutModule.Persist();
|
||||
}
|
||||
|
||||
private static MapViewPreset Capture(Camera cam)
|
||||
{
|
||||
MigrateLegacyIfNeeded(cam);
|
||||
ToGameXZ(cam.transform.position, out float x, out float z);
|
||||
return new MapViewPreset
|
||||
{
|
||||
name = "",
|
||||
x = x,
|
||||
z = z,
|
||||
zoom = cam.orthographicSize,
|
||||
rotationY = cam.transform.eulerAngles.y,
|
||||
};
|
||||
}
|
||||
|
||||
private static void Apply(int index, Camera cam, Action<float> applyRotation)
|
||||
{
|
||||
MigrateLegacyIfNeeded(cam);
|
||||
var presets = List();
|
||||
if (index < 0 || index >= presets.Count) return;
|
||||
ApplyPreset(presets[index], cam, applyRotation);
|
||||
}
|
||||
|
||||
private static void ApplyPreset(MapViewPreset p, Camera cam, Action<float> applyRotation)
|
||||
{
|
||||
SetCameraXZ(cam, p.x, p.z, PopoutModule.Settings.presetUseGameCoords);
|
||||
cam.orthographicSize = Mathf.Clamp(p.zoom, 25f, 10000f);
|
||||
applyRotation(p.rotationY);
|
||||
PanelFinder.UpdateMapForZoom();
|
||||
}
|
||||
|
||||
// Old presets stored Unity world XZ. After a floating-origin rebase those
|
||||
// numbers no longer map to the same place on the railroad. Convert the
|
||||
// whole list the first time we save in game space.
|
||||
private static void MigrateLegacyIfNeeded(Camera cam)
|
||||
{
|
||||
var s = PopoutModule.Settings;
|
||||
if (s.presetUseGameCoords) return;
|
||||
var list = List();
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
{
|
||||
var p = list[i];
|
||||
var world = new Vector3(p.x, cam.transform.position.y, p.z);
|
||||
ToGameXZ(world, out p.x, out p.z);
|
||||
list[i] = p;
|
||||
}
|
||||
s.presetUseGameCoords = true;
|
||||
if (list.Count > 0) Save(list);
|
||||
else PopoutModule.Persist();
|
||||
}
|
||||
|
||||
internal static void ToGameXZ(Vector3 world, out float x, out float z)
|
||||
{
|
||||
try
|
||||
{
|
||||
var g = WorldTransformer.WorldToGame(world);
|
||||
x = g.x; z = g.z;
|
||||
}
|
||||
catch
|
||||
{
|
||||
x = world.x; z = world.z;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void SetCameraXZ(Camera cam, float x, float z, bool gameSpace)
|
||||
{
|
||||
var t = cam.transform;
|
||||
if (!gameSpace)
|
||||
{
|
||||
t.position = new Vector3(x, t.position.y, z);
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var world = WorldTransformer.GameToWorld(new Vector3(x, 0f, z));
|
||||
world.y = t.position.y;
|
||||
t.position = world;
|
||||
}
|
||||
catch
|
||||
{
|
||||
t.position = new Vector3(x, t.position.y, z);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddCurrent(Camera cam)
|
||||
{
|
||||
var list = List();
|
||||
var p = Capture(cam);
|
||||
p.name = NextName(list);
|
||||
list.Add(p);
|
||||
Save(list);
|
||||
}
|
||||
|
||||
private static string NextName(List<MapViewPreset> list)
|
||||
{
|
||||
int n = list.Count + 1;
|
||||
string name = $"View {n}";
|
||||
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var p in list)
|
||||
if (!string.IsNullOrEmpty(p.name)) used.Add(p.name);
|
||||
while (used.Contains(name))
|
||||
{
|
||||
n++;
|
||||
name = $"View {n}";
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private static void Delete(int index)
|
||||
{
|
||||
var list = List();
|
||||
if (index < 0 || index >= list.Count) return;
|
||||
list.RemoveAt(index);
|
||||
Save(list);
|
||||
}
|
||||
|
||||
private static void Rename(int index, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return;
|
||||
var list = List();
|
||||
if (index < 0 || index >= list.Count) return;
|
||||
var p = list[index];
|
||||
p.name = name.Trim();
|
||||
list[index] = p;
|
||||
Save(list);
|
||||
}
|
||||
|
||||
private static void Preview(int index, Camera cam, Action<float> applyRotation)
|
||||
{
|
||||
if (!_stashValid)
|
||||
{
|
||||
var cur = Capture(cam);
|
||||
_stashX = cur.x; _stashZ = cur.z; _stashZoom = cur.zoom; _stashRot = cur.rotationY;
|
||||
_stashValid = true;
|
||||
}
|
||||
Apply(index, cam, applyRotation);
|
||||
}
|
||||
|
||||
private static void CommitEdit(int index, Camera cam, Action<float> applyRotation)
|
||||
{
|
||||
var list = List();
|
||||
if (index < 0 || index >= list.Count)
|
||||
{
|
||||
CancelStash(cam, applyRotation);
|
||||
return;
|
||||
}
|
||||
var p = Capture(cam);
|
||||
p.name = list[index].name;
|
||||
list[index] = p;
|
||||
Save(list);
|
||||
RestoreStash(cam, applyRotation);
|
||||
}
|
||||
|
||||
private static void CancelStash(Camera? cam, Action<float> applyRotation)
|
||||
{
|
||||
if (cam != null) RestoreStash(cam, applyRotation);
|
||||
else _stashValid = false;
|
||||
}
|
||||
|
||||
private static void RestoreStash(Camera cam, Action<float> applyRotation)
|
||||
{
|
||||
if (!_stashValid) return;
|
||||
ApplyPreset(new MapViewPreset
|
||||
{
|
||||
x = _stashX, z = _stashZ, zoom = _stashZoom, rotationY = _stashRot
|
||||
}, cam, applyRotation);
|
||||
_stashValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remembers the map camera between overlay/popout close and the next open.
|
||||
/// MapEnhancer patches OnWindowShown to snap the camera to the player; we
|
||||
/// capture before teardown and write the view back after Show() returns.
|
||||
/// </summary>
|
||||
internal static class MapCameraMemory
|
||||
{
|
||||
public static void Capture(Camera? cam, float rotationY)
|
||||
{
|
||||
if (cam == null) return;
|
||||
var s = PopoutModule.Settings;
|
||||
MapViewPresets.ToGameXZ(cam.transform.position, out s.lastViewX, out s.lastViewZ);
|
||||
s.lastViewZoom = cam.orthographicSize;
|
||||
s.lastViewRot = rotationY;
|
||||
s.lastViewValid = true;
|
||||
s.lastViewIsGame = true;
|
||||
PopoutModule.Persist();
|
||||
}
|
||||
|
||||
public static bool TryRestore(Camera? cam, Action<float> applyRotation)
|
||||
{
|
||||
if (cam == null) return false;
|
||||
var s = PopoutModule.Settings;
|
||||
if (!s.lastViewValid) return false;
|
||||
MapViewPresets.SetCameraXZ(cam, s.lastViewX, s.lastViewZ, s.lastViewIsGame);
|
||||
cam.orthographicSize = Mathf.Clamp(s.lastViewZoom, 25f, 10000f);
|
||||
applyRotation(s.lastViewRot);
|
||||
PanelFinder.UpdateMapForZoom();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
560
src/Modules/Popout/MapWaypointSystem.cs
Normal file
560
src/Modules/Popout/MapWaypointSystem.cs
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
using System.Collections.Generic;
|
||||
using Game.Messages;
|
||||
using HarmonyLib;
|
||||
using Helpers;
|
||||
using Model;
|
||||
using Model.AI;
|
||||
using Track;
|
||||
using UI.Map;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace S3.Modules.Popout;
|
||||
|
||||
/// <summary>
|
||||
/// Destination pins on the map camera RT for Auto Engineer waypoints.
|
||||
/// Vanilla: one pin per loco in Waypoint mode. WaypointQueue: numbered queue.
|
||||
/// </summary>
|
||||
internal static class MapWaypointSystem
|
||||
{
|
||||
private static GameObject? _holder;
|
||||
private static readonly Dictionary<string, WaypointMarker> _markers = new();
|
||||
private static readonly Dictionary<string, Color> _locoColors = new();
|
||||
private static readonly Dictionary<Image, Color> _iconOrig = new();
|
||||
private static int _nextColor;
|
||||
private static float _rebuildTimer;
|
||||
private const float kRebuildInterval = 1.5f;
|
||||
private const float kYOffset = 3600f;
|
||||
private static Sprite? _circle;
|
||||
private static TMP_FontAsset? _tmpFont;
|
||||
|
||||
public static void Install()
|
||||
{
|
||||
if (_holder != null) return;
|
||||
_holder = new GameObject("S3.Waypoint.Holder");
|
||||
Object.DontDestroyOnLoad(_holder);
|
||||
_rebuildTimer = 0f;
|
||||
}
|
||||
|
||||
public static void Uninstall()
|
||||
{
|
||||
ClearAll();
|
||||
if (_holder != null) { Object.Destroy(_holder); _holder = null; }
|
||||
_circle = null;
|
||||
_tmpFont = null;
|
||||
_locoColors.Clear();
|
||||
_nextColor = 0;
|
||||
RestoreLocoColors();
|
||||
}
|
||||
|
||||
public static void Tick(float dt)
|
||||
{
|
||||
if (_holder == null) return;
|
||||
if (!PopoutModule.Settings.waypointsEnabled)
|
||||
{
|
||||
ClearAll();
|
||||
RestoreLocoColors();
|
||||
return;
|
||||
}
|
||||
// Only while the ImGui overlay or OS popout owns the map camera. Cloning
|
||||
// MapIcon templates with the stock map closed (or worse, while it is
|
||||
// opening) can poke MapWindow.Show and steal the camera.
|
||||
if (!S3.Core.Ui.UiService.IsOverlayVisible && !PopoutModule.IsDetached)
|
||||
{
|
||||
ClearAll();
|
||||
RestoreLocoColors();
|
||||
return;
|
||||
}
|
||||
TintLocoIcons();
|
||||
_rebuildTimer -= dt;
|
||||
if (_rebuildTimer <= 0f) { _rebuildTimer = kRebuildInterval; Rebuild(); }
|
||||
}
|
||||
|
||||
public static void SeedState(int handle)
|
||||
{
|
||||
try
|
||||
{
|
||||
var s = PopoutModule.Settings;
|
||||
Native.RRPOPOUT_SetWaypointState(handle, s.waypointsEnabled, s.waypointsSelectedOnly);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
S3.Core.Log.Error($"[popout] waypoint state seed failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryHandleCommand(UICmd cmd, int handle)
|
||||
{
|
||||
var s = PopoutModule.Settings;
|
||||
switch (cmd)
|
||||
{
|
||||
case UICmd.ToggleWaypoints:
|
||||
s.waypointsEnabled = !s.waypointsEnabled;
|
||||
PopoutModule.Persist();
|
||||
SeedState(handle);
|
||||
return true;
|
||||
case UICmd.ToggleWaypointsSelectedOnly:
|
||||
s.waypointsSelectedOnly = !s.waypointsSelectedOnly;
|
||||
PopoutModule.Persist();
|
||||
SeedState(handle);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Rebuild()
|
||||
{
|
||||
try
|
||||
{
|
||||
RebuildInner();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
S3.Core.Log.Error($"[popout] waypoint rebuild: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RebuildInner()
|
||||
{
|
||||
if (_holder == null) return;
|
||||
var tc = TrainController.Shared;
|
||||
if (tc == null) { ClearAll(); return; }
|
||||
|
||||
var wanted = new HashSet<string>();
|
||||
string? selectedId = SelectedLocoId(tc);
|
||||
|
||||
foreach (Car car in tc.Cars)
|
||||
{
|
||||
if (car is not BaseLocomotive loco) continue;
|
||||
if (PopoutModule.Settings.waypointsSelectedOnly &&
|
||||
(selectedId == null || loco.id != selectedId))
|
||||
continue;
|
||||
|
||||
if (!CollectPoints(loco, out var points) || points.Count == 0) continue;
|
||||
|
||||
Color color = ColorForLoco(loco.id);
|
||||
int mapLayer = GetMapLayer(tc);
|
||||
for (int i = 0; i < points.Count; i++)
|
||||
{
|
||||
string key = $"{loco.id}:{i}";
|
||||
wanted.Add(key);
|
||||
int number = i + 1;
|
||||
if (!_markers.TryGetValue(key, out var marker) || marker == null)
|
||||
{
|
||||
marker = CreateMarker(tc, mapLayer, number, points[i].active, color);
|
||||
if (marker == null) continue;
|
||||
_markers[key] = marker;
|
||||
}
|
||||
marker.Configure(points[i].gamePos, number, points[i].active, color);
|
||||
}
|
||||
}
|
||||
|
||||
var stale = new List<string>();
|
||||
foreach (var kv in _markers)
|
||||
{
|
||||
if (!wanted.Contains(kv.Key))
|
||||
{
|
||||
if (kv.Value != null) Object.Destroy(kv.Value.gameObject);
|
||||
stale.Add(kv.Key);
|
||||
}
|
||||
}
|
||||
foreach (var k in stale) _markers.Remove(k);
|
||||
}
|
||||
|
||||
private static bool CollectPoints(BaseLocomotive loco, out List<(Vector3 gamePos, bool active)> points)
|
||||
{
|
||||
if (WaypointQueueBridge.TryGetQueue(loco.id, out points))
|
||||
return true;
|
||||
|
||||
points = new List<(Vector3, bool)>();
|
||||
try
|
||||
{
|
||||
var planner = loco.AutoEngineerPlanner;
|
||||
if (planner == null) return false;
|
||||
object? raw = Traverse.Create(planner).Field("_orders").GetValue();
|
||||
if (raw is not Orders orders) return false;
|
||||
if (orders.Mode != AutoEngineerMode.Waypoint || !orders.Waypoint.HasValue)
|
||||
return false;
|
||||
var loc = Graph.Shared.ResolveLocationString(orders.Waypoint.Value.LocationString);
|
||||
Vector3 pos = Graph.Shared.GetPosition(loc);
|
||||
points.Add((pos, true));
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? SelectedLocoId(TrainController tc)
|
||||
{
|
||||
var sel = tc.SelectedCar;
|
||||
if (sel is BaseLocomotive l) return l.id;
|
||||
if (sel == null) return null;
|
||||
try
|
||||
{
|
||||
foreach (Car c in sel.EnumerateCoupled())
|
||||
if (c is BaseLocomotive loc) return loc.id;
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static readonly Color[] kLocoPalette =
|
||||
{
|
||||
new Color(0.95f, 0.26f, 0.21f), // red
|
||||
new Color(0.20f, 0.60f, 0.98f), // blue
|
||||
new Color(0.18f, 0.80f, 0.44f), // green
|
||||
new Color(0.98f, 0.82f, 0.14f), // yellow
|
||||
new Color(0.68f, 0.35f, 0.92f), // purple
|
||||
new Color(0.10f, 0.85f, 0.85f), // cyan
|
||||
new Color(0.98f, 0.52f, 0.12f), // orange
|
||||
new Color(0.95f, 0.40f, 0.70f), // pink
|
||||
new Color(0.55f, 0.90f, 0.20f), // lime
|
||||
new Color(0.30f, 0.45f, 0.95f), // indigo
|
||||
new Color(0.90f, 0.30f, 0.45f), // rose
|
||||
new Color(0.20f, 0.72f, 0.72f), // teal
|
||||
};
|
||||
|
||||
internal static Color ColorForLoco(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id)) id = "?";
|
||||
if (_locoColors.TryGetValue(id, out var c)) return c;
|
||||
c = kLocoPalette[_nextColor % kLocoPalette.Length];
|
||||
if (_nextColor >= kLocoPalette.Length)
|
||||
{
|
||||
float hue = ((_nextColor * 0.6180339887f) % 1f);
|
||||
c = Color.HSVToRGB(hue, 0.82f, 1f);
|
||||
}
|
||||
_nextColor++;
|
||||
_locoColors[id] = c;
|
||||
return c;
|
||||
}
|
||||
|
||||
internal static bool IsAutoEngineerActive(BaseLocomotive loco)
|
||||
{
|
||||
try
|
||||
{
|
||||
var planner = loco.AutoEngineerPlanner;
|
||||
if (planner == null) return false;
|
||||
object? raw = Traverse.Create(planner).Field("_orders").GetValue();
|
||||
return raw is Orders orders && orders.Mode != AutoEngineerMode.Off;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TintLocoIcons()
|
||||
{
|
||||
var tc = TrainController.Shared;
|
||||
if (tc == null) { RestoreLocoColors(); return; }
|
||||
|
||||
var seen = new HashSet<Image>();
|
||||
foreach (Car car in tc.Cars)
|
||||
{
|
||||
if (car is not BaseLocomotive loco) continue;
|
||||
var icon = Traverse.Create(loco).Field<MapIcon>("MapIcon").Value;
|
||||
if (icon == null) continue;
|
||||
bool ae = IsAutoEngineerActive(loco);
|
||||
Color tint = ae ? ColorForLoco(loco.id) : default;
|
||||
foreach (var img in icon.GetComponentsInChildren<Image>(true))
|
||||
{
|
||||
if (img == null) continue;
|
||||
seen.Add(img);
|
||||
if (ae)
|
||||
{
|
||||
if (!_iconOrig.ContainsKey(img))
|
||||
_iconOrig[img] = Color.white;
|
||||
img.color = tint;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!_iconOrig.ContainsKey(img))
|
||||
_iconOrig[img] = img.color;
|
||||
else
|
||||
img.color = _iconOrig[img];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_iconOrig.Count == seen.Count) return;
|
||||
var dropped = new List<Image>();
|
||||
foreach (var kv in _iconOrig)
|
||||
{
|
||||
if (kv.Key == null || !seen.Contains(kv.Key))
|
||||
{
|
||||
if (kv.Key != null) kv.Key.color = kv.Value;
|
||||
dropped.Add(kv.Key);
|
||||
}
|
||||
}
|
||||
foreach (var img in dropped) _iconOrig.Remove(img);
|
||||
}
|
||||
|
||||
private static void RestoreLocoColors()
|
||||
{
|
||||
foreach (var kv in _iconOrig)
|
||||
{
|
||||
if (kv.Key != null)
|
||||
kv.Key.color = kv.Value;
|
||||
}
|
||||
_iconOrig.Clear();
|
||||
}
|
||||
|
||||
private static WaypointMarker? CreateMarker(TrainController tc, int mapLayer, int number, bool active, Color color)
|
||||
{
|
||||
var template = GetAnyCarIcon(tc);
|
||||
if (template == null || _holder == null) return null;
|
||||
|
||||
var go = Object.Instantiate(template.gameObject, _holder.transform);
|
||||
go.name = "S3_Waypoint_Marker";
|
||||
go.SetActive(false);
|
||||
SetLayerRecursive(go, mapLayer);
|
||||
|
||||
var children = new List<Transform>();
|
||||
foreach (Transform child in go.transform) children.Add(child);
|
||||
foreach (var child in children) Object.Destroy(child.gameObject);
|
||||
|
||||
var mapIcon = go.GetComponent<MapIcon>();
|
||||
if (mapIcon != null)
|
||||
{
|
||||
mapIcon.enabled = false;
|
||||
Object.Destroy(mapIcon);
|
||||
}
|
||||
|
||||
var pinGo = new GameObject("pin");
|
||||
pinGo.layer = mapLayer;
|
||||
pinGo.transform.SetParent(go.transform, false);
|
||||
var img = pinGo.AddComponent<Image>();
|
||||
img.sprite = CircleSprite();
|
||||
img.type = Image.Type.Simple;
|
||||
var pinRt = pinGo.GetComponent<RectTransform>();
|
||||
pinRt.sizeDelta = new Vector2(1f, 1f);
|
||||
pinRt.anchoredPosition = Vector2.zero;
|
||||
|
||||
TryTmpFont(out var font);
|
||||
var numHold = new GameObject("num");
|
||||
numHold.layer = mapLayer;
|
||||
numHold.transform.SetParent(go.transform, false);
|
||||
var holdRt = numHold.AddComponent<RectTransform>();
|
||||
holdRt.sizeDelta = new Vector2(1.2f, 1.2f);
|
||||
holdRt.anchoredPosition = Vector2.zero;
|
||||
holdRt.localRotation = Quaternion.identity;
|
||||
holdRt.localScale = Vector3.one;
|
||||
|
||||
// 8-direction stroke so the digit stays readable on a car of the same hue.
|
||||
const float kStroke = 0.07f;
|
||||
var outlines = new TextMeshProUGUI[8];
|
||||
int oi = 0;
|
||||
for (int dy = -1; dy <= 1; dy++)
|
||||
for (int dx = -1; dx <= 1; dx++)
|
||||
{
|
||||
if (dx == 0 && dy == 0) continue;
|
||||
outlines[oi++] = MakeDigit(numHold.transform, mapLayer, font,
|
||||
OutlineColor(color), new Vector2(dx * kStroke, dy * kStroke));
|
||||
}
|
||||
|
||||
var label = MakeDigit(numHold.transform, mapLayer, font, color, Vector2.zero);
|
||||
|
||||
var canvas = go.GetComponent<Canvas>();
|
||||
if (canvas != null)
|
||||
{
|
||||
// Cloned MapIcons are often Screen Space-Camera, which keeps text
|
||||
// upright on screen. World Space glues the digits to the map so they
|
||||
// yaw with the camera. UI faces -local Z, so look down the world -Y
|
||||
// axis to show the front of the canvas (avoids mirrored letters).
|
||||
canvas.renderMode = RenderMode.WorldSpace;
|
||||
canvas.additionalShaderChannels |= AdditionalCanvasShaderChannels.TexCoord1
|
||||
| AdditionalCanvasShaderChannels.TexCoord2
|
||||
| AdditionalCanvasShaderChannels.Normal
|
||||
| AdditionalCanvasShaderChannels.Tangent;
|
||||
}
|
||||
|
||||
var marker = go.AddComponent<WaypointMarker>();
|
||||
marker.Init(img, label, outlines);
|
||||
marker.Configure(Vector3.zero, number, active, color);
|
||||
go.SetActive(true);
|
||||
return marker;
|
||||
}
|
||||
|
||||
private static TextMeshProUGUI MakeDigit(Transform parent, int layer, TMP_FontAsset? font,
|
||||
Color color, Vector2 offset)
|
||||
{
|
||||
var go = new GameObject(offset == Vector2.zero ? "fill" : "ol");
|
||||
go.layer = layer;
|
||||
go.transform.SetParent(parent, false);
|
||||
var tmp = go.AddComponent<TextMeshProUGUI>();
|
||||
tmp.alignment = TextAlignmentOptions.Center;
|
||||
tmp.fontSize = 6f;
|
||||
tmp.fontStyle = FontStyles.Bold;
|
||||
tmp.color = color;
|
||||
tmp.raycastTarget = false;
|
||||
if (font != null) tmp.font = font;
|
||||
var rt = go.GetComponent<RectTransform>();
|
||||
rt.sizeDelta = new Vector2(1.2f, 1.2f);
|
||||
rt.anchoredPosition = offset;
|
||||
rt.localRotation = Quaternion.identity;
|
||||
rt.localScale = Vector3.one;
|
||||
return tmp;
|
||||
}
|
||||
|
||||
// Light digits get a black stroke; dark digits get a white one.
|
||||
internal static Color OutlineColor(Color fill)
|
||||
{
|
||||
float lum = fill.r * 0.299f + fill.g * 0.587f + fill.b * 0.114f;
|
||||
return lum > 0.55f ? new Color(0.05f, 0.05f, 0.06f, 1f)
|
||||
: new Color(1f, 1f, 1f, 1f);
|
||||
}
|
||||
|
||||
private static bool TryTmpFont(out TMP_FontAsset font)
|
||||
{
|
||||
font = _tmpFont!;
|
||||
if (_tmpFont != null) { font = _tmpFont; return true; }
|
||||
try
|
||||
{
|
||||
_tmpFont = TMP_Settings.defaultFontAsset;
|
||||
font = _tmpFont;
|
||||
return font != null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ClearAll()
|
||||
{
|
||||
foreach (var kv in _markers)
|
||||
if (kv.Value != null) Object.Destroy(kv.Value.gameObject);
|
||||
_markers.Clear();
|
||||
}
|
||||
|
||||
private static MapIcon? GetAnyCarIcon(TrainController tc)
|
||||
{
|
||||
foreach (Car car in tc.Cars)
|
||||
{
|
||||
if (car.IsLocomotive) continue;
|
||||
var icon = Traverse.Create(car).Field<MapIcon>("MapIcon").Value;
|
||||
if (icon != null) return icon;
|
||||
}
|
||||
foreach (Car car in tc.Cars)
|
||||
{
|
||||
var icon = Traverse.Create(car).Field<MapIcon>("MapIcon").Value;
|
||||
if (icon != null) return icon;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int GetMapLayer(TrainController tc)
|
||||
{
|
||||
foreach (Car car in tc.Cars)
|
||||
{
|
||||
var icon = Traverse.Create(car).Field<MapIcon>("MapIcon").Value;
|
||||
if (icon != null) return icon.gameObject.layer;
|
||||
}
|
||||
return LayerMask.NameToLayer("Map");
|
||||
}
|
||||
|
||||
private static void SetLayerRecursive(GameObject go, int layer)
|
||||
{
|
||||
go.layer = layer;
|
||||
foreach (Transform child in go.transform)
|
||||
SetLayerRecursive(child.gameObject, layer);
|
||||
}
|
||||
|
||||
private static Sprite CircleSprite()
|
||||
{
|
||||
if (_circle != null) return _circle;
|
||||
int radius = 32;
|
||||
int size = radius * 2;
|
||||
var tex = new Texture2D(size, size, TextureFormat.RGBA32, mipChain: false);
|
||||
tex.filterMode = FilterMode.Bilinear;
|
||||
var pixels = new Color32[size * size];
|
||||
float c = radius - 0.5f;
|
||||
for (int y = 0; y < size; y++)
|
||||
for (int x = 0; x < size; x++)
|
||||
{
|
||||
float dist = Mathf.Sqrt((x - c) * (x - c) + (y - c) * (y - c));
|
||||
byte a = (byte)(Mathf.Clamp01(radius - dist) * 255f);
|
||||
pixels[y * size + x] = new Color32(255, 255, 255, a);
|
||||
}
|
||||
tex.SetPixels32(pixels);
|
||||
tex.Apply();
|
||||
_circle = Sprite.Create(tex, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f));
|
||||
return _circle;
|
||||
}
|
||||
}
|
||||
|
||||
internal class WaypointMarker : MonoBehaviour
|
||||
{
|
||||
private Image? _img;
|
||||
private TextMeshProUGUI? _label;
|
||||
private TextMeshProUGUI[] _outlines = System.Array.Empty<TextMeshProUGUI>();
|
||||
private Canvas? _canvas;
|
||||
private Vector3 _gamePos;
|
||||
private bool _active;
|
||||
|
||||
public void Init(Image pin, TextMeshProUGUI? label, TextMeshProUGUI[] outlines)
|
||||
{
|
||||
_img = pin;
|
||||
_label = label;
|
||||
_outlines = outlines ?? System.Array.Empty<TextMeshProUGUI>();
|
||||
_canvas = GetComponent<Canvas>();
|
||||
}
|
||||
|
||||
public void Configure(Vector3 gamePos, int number, bool active, Color color)
|
||||
{
|
||||
_gamePos = gamePos;
|
||||
_active = active;
|
||||
string text = number > 0 ? number.ToString() : "";
|
||||
bool show = number > 0;
|
||||
if (_label != null)
|
||||
{
|
||||
_label.text = text;
|
||||
_label.enabled = show;
|
||||
_label.color = color;
|
||||
}
|
||||
var stroke = MapWaypointSystem.OutlineColor(color);
|
||||
foreach (var ol in _outlines)
|
||||
{
|
||||
if (ol == null) continue;
|
||||
ol.text = text;
|
||||
ol.enabled = show;
|
||||
ol.color = stroke;
|
||||
}
|
||||
if (_img != null)
|
||||
{
|
||||
// Dark disc so the numbered color stays readable; brighter when this
|
||||
// is the active (next) waypoint.
|
||||
float v = active ? 0.38f : 0.22f;
|
||||
Color.RGBToHSV(color, out float h, out float s, out _);
|
||||
_img.color = Color.HSVToRGB(h, Mathf.Min(s, 0.85f), v);
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
try
|
||||
{
|
||||
var worldPos = WorldTransformer.GameToWorld(_gamePos);
|
||||
worldPos.y += 3600f;
|
||||
// Look down -Y so the UI (which faces -local Z) points at the overhead
|
||||
// map camera. World-fixed up keeps the digits glued to the map as it yaws.
|
||||
transform.SetPositionAndRotation(
|
||||
worldPos, Quaternion.LookRotation(Vector3.down, Vector3.forward));
|
||||
|
||||
if (_canvas == null) _canvas = GetComponent<Canvas>();
|
||||
if (_canvas != null && _canvas.renderMode != RenderMode.WorldSpace)
|
||||
_canvas.renderMode = RenderMode.WorldSpace;
|
||||
|
||||
var mb = MapBuilder.Shared;
|
||||
float scaleMul = _active ? 0.0024f : 0.0018f;
|
||||
if (mb?.mapCamera != null)
|
||||
transform.localScale = Vector3.one * (mb.mapCamera.orthographicSize * 8f * scaleMul);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace S3.Modules.Popout {
|
||||
|
||||
|
|
@ -72,6 +73,29 @@ namespace S3.Modules.Popout {
|
|||
TrackLabelSetAllZoom = 45, // y = orthographicSize beyond which ALL labels hide
|
||||
ToggleAvoidTrackLabels = 46, // push labels off their own track line
|
||||
TrackLabelSetFontSizeMin = 47, // y = minimum font size px [4, max]; labels auto-scale with zoom
|
||||
PresetAdd = 48, // save current camera as a new view preset
|
||||
PresetApply = 49, // jump to preset; y = 0-based index
|
||||
PresetDelete = 50, // delete preset; y = 0-based index
|
||||
PresetRename = 51, // rename preset; y = index, name via GetPresetRenameName
|
||||
PresetPreview = 52, // stash current view and jump to preset; y = index
|
||||
PresetCommitEdit = 53, // write current camera into preset and restore stash; y = index
|
||||
PresetCancelEdit = 54, // restore stash without saving camera
|
||||
ToggleWaypoints = 55, // toggle AE waypoint pins on the map
|
||||
ToggleWaypointsSelectedOnly = 56, // filter waypoint pins to the selected loco
|
||||
ToggleRadio = 57, // radio-control map mode
|
||||
RadioPin = 58, // pin the currently selected consist loco
|
||||
RadioSelect = 59, // select pinned loco; y = index
|
||||
RadioUnpin = 60, // unpin; y = index
|
||||
RadioRename = 61, // rename pin; y = index, name via GetRadioRenameName
|
||||
RadioSetTool = 62, // y = 0 idle, 1 waypoint-place mode
|
||||
RadioSetForward = 63, // y = 0 reverse, 1 forward
|
||||
RadioSetSpeed = 64, // y = mph
|
||||
RadioStop = 65, // AE Off on selected pin
|
||||
RadioFollow = 66, // follow selected pin on the map
|
||||
RadioJump = 67, // jump map to pin; y = index
|
||||
RadioWpChoose = 68, // y = 0 Go, 1 Couple, 2 Pickup, 3 Dropoff, 4 Cut
|
||||
RadioWpCount = 69, // y = car count (>= 1)
|
||||
RadioWpCancel = 70, // close the waypoint order popup
|
||||
}
|
||||
|
||||
// Must match MapThemeData in native/include/shared_types.h exactly (36 floats = 144 bytes).
|
||||
|
|
@ -102,6 +126,10 @@ namespace S3.Modules.Popout {
|
|||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
|
||||
public static extern int RRPOPOUT_CreateWindow(string title, int width, int height);
|
||||
|
||||
// Skip map ImGui chrome (toolbar, radio, presets). Blit the frame texture only.
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void RRPOPOUT_SetPlainContent(int windowHandle, bool plain);
|
||||
|
||||
// Set the source texture and the UV sub-rect to blit next frame.
|
||||
// Call this on the main thread immediately before IssuePluginEvent.
|
||||
// u0,v0 = top-left UV in D3D convention (V=0 at top); u1,v1 = bottom-right.
|
||||
|
|
@ -212,6 +240,16 @@ namespace S3.Modules.Popout {
|
|||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern int RRPOPOUT_OverlayWantsMouse();
|
||||
|
||||
// 1 when an ImGui text field has focus (preset rename). C# swallows game keys.
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern int RRPOPOUT_OverlayWantsKeyboard();
|
||||
|
||||
// Per-frame overlay keyboard: UTF-16 characters plus a key-down bit mask.
|
||||
// Bits: 0 Backspace, 1 Delete, 2 Enter, 3 Escape, 4 Left, 5 Right, 6 Home,
|
||||
// 7 End, 8 Tab, 9 A, 10 C, 11 V, 12 X. mods: 1 Ctrl, 2 Shift, 4 Alt.
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
|
||||
public static extern void RRPOPOUT_SetOverlayKeyboard(string chars, uint keyDown, uint mods);
|
||||
|
||||
// Drains queued in-game map input (drag/zoom over the map image). Events are
|
||||
// in normalized [0,1] image space (top-left origin); forward to the map camera.
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
|
|
@ -244,6 +282,10 @@ namespace S3.Modules.Popout {
|
|||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void RRPOPOUT_SetOverlayAlpha(float alpha);
|
||||
|
||||
// Dim overlay + ignore ImGui mouse while the map-opened pie is up.
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void RRPOPOUT_SetOverlayPieBlock(bool blocked);
|
||||
|
||||
// Set the map image alpha [0.0, 1.0]. Independent of chrome alpha.
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void RRPOPOUT_SetOverlayMapAlpha(float alpha);
|
||||
|
|
@ -311,5 +353,38 @@ namespace S3.Modules.Popout {
|
|||
float allLabelsZoom,
|
||||
bool avoidTrack,
|
||||
float fontSizeMin);
|
||||
|
||||
// Named camera-view presets (newline-separated names).
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
|
||||
public static extern void RRPOPOUT_SetPresetList(int windowHandle, string names);
|
||||
|
||||
// UTF-16 name currently in the preset rename field (native InputText buffer).
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
|
||||
public static extern void RRPOPOUT_GetPresetRenameName(int windowHandle, StringBuilder outBuf, int maxChars);
|
||||
|
||||
// Seed waypoint-pin toggles for the gear menu.
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void RRPOPOUT_SetWaypointState(int windowHandle, bool enabled, bool selectedOnly);
|
||||
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
|
||||
public static extern void RRPOPOUT_SetRadioList(int windowHandle, string names, uint[] colors, int colorCount);
|
||||
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void RRPOPOUT_SetRadioState(int windowHandle,
|
||||
bool radioOn, int selected, int tool, bool wqInstalled,
|
||||
ulong aeBits, bool forward, float speed);
|
||||
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
|
||||
public static extern void RRPOPOUT_GetRadioRenameName(int windowHandle, StringBuilder outBuf, int maxChars);
|
||||
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void RRPOPOUT_SetRadioGhost(int windowHandle,
|
||||
bool visible, float u, float v, float angleDeg, uint color);
|
||||
|
||||
// stage: 0 off, 1 choose WQ order, 2 enter car count. u/v = Unity viewport.
|
||||
// flags bit0 = snapped to a free coupler (Couple/Pickup enabled).
|
||||
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void RRPOPOUT_SetRadioWpPopup(int windowHandle,
|
||||
int stage, float u, float v, int flags, int count);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,28 @@ internal static class NativeLoader
|
|||
[DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr LoadLibrary(string lpFileName);
|
||||
|
||||
[DllImport("kernel32")]
|
||||
private static extern uint SetErrorMode(uint uMode);
|
||||
|
||||
[DllImport("user32", CharSet = CharSet.Unicode)]
|
||||
private static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
|
||||
|
||||
// ERROR_MOD_NOT_FOUND: the loader resolved S3Native.dll itself but couldn't find one
|
||||
// of its dependencies. In practice that's always the MSVC runtime DLLs
|
||||
// (msvcp140/vcruntime140[_1].dll), which only ship via the VC++ Redistributable —
|
||||
// Windows doesn't include them by default.
|
||||
private const int ErrorModNotFound = 126;
|
||||
private const uint MbIconWarning = 0x30;
|
||||
|
||||
// Suppresses the OS's own blocking "X.dll was not found" dialog for the
|
||||
// LoadLibrary call below, so a missing dependency comes back as a plain
|
||||
// LoadLibrary failure instead of a hidden system dialog that stalls the
|
||||
// whole game window (what looked like "the game just doesn't launch").
|
||||
private const uint SemFailCriticalErrors = 0x0001;
|
||||
private const uint SemNoOpenFileErrorBox = 0x8000;
|
||||
|
||||
private static bool _loaded;
|
||||
private static bool _warnedMissingRuntime;
|
||||
|
||||
public static bool EnsureLoaded()
|
||||
{
|
||||
|
|
@ -32,10 +53,14 @@ internal static class NativeLoader
|
|||
return false;
|
||||
}
|
||||
|
||||
uint prevErrorMode = SetErrorMode(SemFailCriticalErrors | SemNoOpenFileErrorBox);
|
||||
IntPtr handle = LoadLibrary(dll);
|
||||
SetErrorMode(prevErrorMode);
|
||||
if (handle == IntPtr.Zero)
|
||||
{
|
||||
Log.Error($"[popout] LoadLibrary failed (Win32 error {Marshal.GetLastWin32Error()}): {dll}");
|
||||
int error = Marshal.GetLastWin32Error();
|
||||
Log.Error($"[popout] LoadLibrary failed (Win32 error {error}): {dll}");
|
||||
if (error == ErrorModNotFound) WarnMissingRuntime();
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -43,4 +68,20 @@ internal static class NativeLoader
|
|||
Log.Info($"[popout] native S3Native.dll loaded from {dll}");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Shown via a plain Win32 MessageBox rather than our ImGui overlay, since the
|
||||
// overlay's renderer lives in the very DLL that just failed to load.
|
||||
private static void WarnMissingRuntime()
|
||||
{
|
||||
if (_warnedMissingRuntime) return;
|
||||
_warnedMissingRuntime = true;
|
||||
MessageBox(IntPtr.Zero,
|
||||
"Seton's Special Sauce needs the Microsoft Visual C++ Redistributable " +
|
||||
"(x64) to render the map, and it isn't installed.\n\n" +
|
||||
"Download and install it from:\n" +
|
||||
"https://aka.ms/vs/17/release/vc_redist.x64.exe\n\n" +
|
||||
"The rest of the mod will keep working; only the map window is affected.",
|
||||
"Seton's Special Sauce - Missing VC++ Runtime",
|
||||
MbIconWarning);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,13 +45,6 @@ public sealed class PopoutModule : IModule
|
|||
private static bool _pendingOverlayOpen;
|
||||
private static float _pendingOverlayTimer;
|
||||
|
||||
// Deferred window scale restore: a 1-frame counter so any Toggle() close animation
|
||||
// plays at scale=0 (invisible) before we put the panel back to its normal size.
|
||||
// Static class has no MonoBehaviour, so we tick this down in Tick() instead of a coroutine.
|
||||
private static Window? _pendingRestoreWindow;
|
||||
private static Vector3 _pendingRestoreScale;
|
||||
private static int _pendingRestoreFrames;
|
||||
|
||||
public static bool IsDetached => _activePanel != null;
|
||||
|
||||
public PopoutModule()
|
||||
|
|
@ -83,6 +76,7 @@ public sealed class PopoutModule : IModule
|
|||
|
||||
MapIconCuller.Install();
|
||||
EotdSystem.Install();
|
||||
MapWaypointSystem.Install();
|
||||
|
||||
_host = new GameObject("S3.Popout.Host");
|
||||
Object.DontDestroyOnLoad(_host);
|
||||
|
|
@ -97,6 +91,7 @@ public sealed class PopoutModule : IModule
|
|||
_pendingOverlayOpen = false;
|
||||
MapIconCuller.Uninstall();
|
||||
EotdSystem.Uninstall();
|
||||
MapWaypointSystem.Uninstall();
|
||||
if (_host != null) { Object.Destroy(_host); _host = null; }
|
||||
}
|
||||
|
||||
|
|
@ -162,23 +157,13 @@ public sealed class PopoutModule : IModule
|
|||
// While the popout is live, keep the in-game window zeroed every frame.
|
||||
// MapWindow.Show() starts a Unity animation that can re-set localScale to
|
||||
// non-zero across subsequent frames — this override wins each tick.
|
||||
if (_activePanel != null && _hiddenWindow != null &&
|
||||
_hiddenWindow.transform is RectTransform suppRect && suppRect.localScale != Vector3.zero)
|
||||
{
|
||||
suppRect.localScale = Vector3.zero;
|
||||
}
|
||||
|
||||
// Deferred window scale restore (see RestoreInGameWindow).
|
||||
if (_pendingRestoreFrames > 0 && --_pendingRestoreFrames == 0)
|
||||
{
|
||||
if (_pendingRestoreWindow != null && _pendingRestoreWindow.transform is RectTransform r)
|
||||
r.localScale = _pendingRestoreScale;
|
||||
_pendingRestoreWindow = null;
|
||||
}
|
||||
StockMapGuard.Tick();
|
||||
|
||||
float dt = UnityEngine.Time.deltaTime;
|
||||
MapIconCuller.TickFade(dt);
|
||||
EotdSystem.Tick(dt);
|
||||
try { MapWaypointSystem.Tick(dt); }
|
||||
catch (System.Exception ex) { Log.Error($"[popout] waypoint tick: {ex.Message}"); }
|
||||
|
||||
if (_hotkey.Down())
|
||||
Toggle();
|
||||
|
|
@ -250,8 +235,8 @@ public sealed class PopoutModule : IModule
|
|||
}
|
||||
|
||||
UiService.MapBypass = true;
|
||||
MapWindow.Show();
|
||||
UiService.MapBypass = false;
|
||||
try { MapWindow.Show(); }
|
||||
finally { UiService.MapBypass = false; }
|
||||
|
||||
if (!PanelFinder.IsMapReady())
|
||||
{
|
||||
|
|
@ -298,15 +283,16 @@ public sealed class PopoutModule : IModule
|
|||
// Close the window while localScale is still zero (invisible) to prevent a
|
||||
// 1-frame flash. Toggle() is used instead of SetActive(false) so MapWindow
|
||||
// remains findable by FindObjectOfType on the next open.
|
||||
UiService.MapBypass = true;
|
||||
MapWindow.Toggle();
|
||||
UiService.MapBypass = false;
|
||||
try
|
||||
{
|
||||
UiService.MapBypass = true;
|
||||
MapWindow.Toggle();
|
||||
}
|
||||
finally { UiService.MapBypass = false; }
|
||||
}
|
||||
// Defer scale restore by 1 frame so any Toggle() close animation plays at
|
||||
// scale=0 before the panel snaps back to its normal size.
|
||||
_pendingRestoreWindow = _hiddenWindow;
|
||||
_pendingRestoreScale = _savedWindowScale;
|
||||
_pendingRestoreFrames = 1;
|
||||
// RestoreInGameWindow still closes the stock window; StockMapGuard keeps
|
||||
// it at scale zero while the module is enabled, so skip the deferred
|
||||
// scale snap-back that used to flash the vanilla map.
|
||||
_hiddenWindow = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,33 @@ public class PopoutSettings
|
|||
public bool trackLabelAvoidTrack = false; // push labels perpendicular so they don't cover track lines
|
||||
public float trackLabelFontSizeMin = 10f; // smallest pixel size (at zoom limit); auto-scales between this and trackLabelFontSize
|
||||
|
||||
// Named camera bookmarks — parallel primitive arrays so JsonUtility on this
|
||||
// Mono runtime cannot drop (or fail to parse) a nested struct array and reset
|
||||
// the whole settings file (which would disable the Map Module and reopen the
|
||||
// stock map). Lengths are kept in lockstep by MapViewPresets.
|
||||
public string[] presetNames = new string[0];
|
||||
public float[] presetX = new float[0];
|
||||
public float[] presetZ = new float[0];
|
||||
public float[] presetZoom = new float[0];
|
||||
public float[] presetRot = new float[0];
|
||||
// True once presets / last-view are stored as game-space XZ (WorldTransformer).
|
||||
// False = legacy Unity world XZ, which breaks after origin rebase / teleport.
|
||||
public bool presetUseGameCoords;
|
||||
public bool lastViewIsGame;
|
||||
|
||||
// Last map camera view (overlay + popout). Primitive fields so JsonUtility
|
||||
// cannot drop them. Restored on reopen because MapWindow.Show + MapEnhancer
|
||||
// recenters on the player every time the stock window is shown.
|
||||
public bool lastViewValid;
|
||||
public float lastViewX;
|
||||
public float lastViewZ;
|
||||
public float lastViewZoom = 500f;
|
||||
public float lastViewRot;
|
||||
|
||||
// Auto Engineer waypoint pins on the map.
|
||||
public bool waypointsEnabled = true;
|
||||
public bool waypointsSelectedOnly = false;
|
||||
|
||||
// Custom theme colors — edited live in the Settings color picker.
|
||||
// Initialized to S3 Dark so first-launch looks reasonable before the user tunes it.
|
||||
public MapThemeData customTheme = new MapThemeData {
|
||||
|
|
|
|||
643
src/Modules/Popout/TrackLabelService.cs
Normal file
643
src/Modules/Popout/TrackLabelService.cs
Normal file
|
|
@ -0,0 +1,643 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Helpers;
|
||||
using Model.Ops;
|
||||
using Track;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.Popout;
|
||||
|
||||
/// <summary>
|
||||
/// Industry / track-name labels for the map overlay and OS popout.
|
||||
/// Rebuilds span clusters every ~5 s, projects them through the map camera each
|
||||
/// frame, and pushes UV + names to native for ImGui drawing.
|
||||
/// </summary>
|
||||
internal static class TrackLabelService
|
||||
{
|
||||
private static IndustryComponent[]? _industryComponents;
|
||||
private static bool _effectiveMergeEnabled = true;
|
||||
|
||||
private static readonly List<(Vector3 centroid, Vector3 trackDir, Vector3[] anchors, string name, int utilityType)>
|
||||
_trackSpanLabels = new();
|
||||
private static readonly List<(Vector3 centroid, string name, int utilityType)> _industryLabels = new();
|
||||
|
||||
private static float _spawnPointTimer = 99f;
|
||||
private static int _lastLabelCount = -1;
|
||||
private static readonly StringBuilder _labelNames = new();
|
||||
|
||||
private static Vector3[] _labelWorldOffsets = System.Array.Empty<Vector3>();
|
||||
private static float[] _labelUs = System.Array.Empty<float>();
|
||||
private static float[] _labelVs = System.Array.Empty<float>();
|
||||
private static float[] _labelAngles = System.Array.Empty<float>();
|
||||
private static float[] _labelScales = System.Array.Empty<float>();
|
||||
private static float[] _anchorUs = System.Array.Empty<float>();
|
||||
private static float[] _anchorVs = System.Array.Empty<float>();
|
||||
private static int[] _anchorStarts = System.Array.Empty<int>();
|
||||
private static int[] _anchorCounts = System.Array.Empty<int>();
|
||||
private static int _totalAnchorCount;
|
||||
|
||||
private const float kMergeDistGame = 250f;
|
||||
private static readonly float[] s_emptyFloat = System.Array.Empty<float>();
|
||||
private static readonly int[] s_emptyInt = System.Array.Empty<int>();
|
||||
|
||||
public static void Reset()
|
||||
{
|
||||
_spawnPointTimer = 99f;
|
||||
_trackSpanLabels.Clear();
|
||||
_industryLabels.Clear();
|
||||
_lastLabelCount = -1;
|
||||
_industryComponents = null;
|
||||
}
|
||||
|
||||
public static void SeedStyle(int handle)
|
||||
{
|
||||
var s = PopoutModule.Settings;
|
||||
Native.RRPOPOUT_SetTrackLabelStyle(handle,
|
||||
s.trackLabelFontSize, s.trackLabelLineThickness, s.trackLabelZoomLimit,
|
||||
s.trackLeaderLinesEnabled, s.trackCollisionEnabled, s.trackLabelParallel,
|
||||
s.trackLabelMergeEnabled, s.trackLabelMergeZoom,
|
||||
s.trackIndustryLabelZoom,
|
||||
s.trackUtilityRepairEnabled, s.trackUtilityDieselEnabled,
|
||||
s.trackUtilityLoaderEnabled, s.trackUtilityInterchangeEnabled,
|
||||
s.trackUtilityZoomLimit,
|
||||
s.trackAllLabelsZoomLimit,
|
||||
s.trackLabelAvoidTrack,
|
||||
s.trackLabelFontSizeMin);
|
||||
}
|
||||
|
||||
public static void SeedEnabled(int handle)
|
||||
{
|
||||
Native.RRPOPOUT_SetTrackLabelsEnabled(handle, PopoutModule.Settings.trackLabelsEnabled);
|
||||
}
|
||||
|
||||
public static bool TryHandleCommand(UICmd cmd, float y, int handle)
|
||||
{
|
||||
var s = PopoutModule.Settings;
|
||||
switch (cmd)
|
||||
{
|
||||
case UICmd.ToggleTrackLabels:
|
||||
s.trackLabelsEnabled = !s.trackLabelsEnabled;
|
||||
PopoutModule.Persist();
|
||||
Native.RRPOPOUT_SetTrackLabelsEnabled(handle, s.trackLabelsEnabled);
|
||||
if (!s.trackLabelsEnabled)
|
||||
Clear(handle);
|
||||
return true;
|
||||
case UICmd.TrackLabelSetFontSize:
|
||||
s.trackLabelFontSize = Mathf.Clamp(y, 8f, 24f);
|
||||
PopoutModule.Persist();
|
||||
SeedStyle(handle);
|
||||
return true;
|
||||
case UICmd.TrackLabelSetLineThick:
|
||||
s.trackLabelLineThickness = Mathf.Clamp(y, 1f, 4f);
|
||||
PopoutModule.Persist();
|
||||
SeedStyle(handle);
|
||||
return true;
|
||||
case UICmd.TrackLabelSetZoomLimit:
|
||||
s.trackLabelZoomLimit = Mathf.Clamp(y, 200f, 8000f);
|
||||
PopoutModule.Persist();
|
||||
SeedStyle(handle);
|
||||
return true;
|
||||
case UICmd.ToggleLeaderLines:
|
||||
s.trackLeaderLinesEnabled = !s.trackLeaderLinesEnabled;
|
||||
PopoutModule.Persist();
|
||||
SeedStyle(handle);
|
||||
return true;
|
||||
case UICmd.ToggleCollision:
|
||||
s.trackCollisionEnabled = !s.trackCollisionEnabled;
|
||||
PopoutModule.Persist();
|
||||
SeedStyle(handle);
|
||||
return true;
|
||||
case UICmd.ToggleParallelLabels:
|
||||
s.trackLabelParallel = !s.trackLabelParallel;
|
||||
PopoutModule.Persist();
|
||||
SeedStyle(handle);
|
||||
return true;
|
||||
case UICmd.ToggleMergeLabels:
|
||||
s.trackLabelMergeEnabled = !s.trackLabelMergeEnabled;
|
||||
PopoutModule.Persist();
|
||||
SeedStyle(handle);
|
||||
ForceRebuild();
|
||||
return true;
|
||||
case UICmd.TrackLabelSetMergeZoom:
|
||||
s.trackLabelMergeZoom = Mathf.Clamp(y, 50f, 8000f);
|
||||
PopoutModule.Persist();
|
||||
return true;
|
||||
case UICmd.TrackLabelSetIndustryZoom:
|
||||
s.trackIndustryLabelZoom = Mathf.Clamp(y, 200f, 8000f);
|
||||
PopoutModule.Persist();
|
||||
return true;
|
||||
case UICmd.ToggleUtilityRepairLabels:
|
||||
s.trackUtilityRepairEnabled = !s.trackUtilityRepairEnabled;
|
||||
PopoutModule.Persist();
|
||||
ForceRebuild();
|
||||
return true;
|
||||
case UICmd.ToggleUtilityDieselLabels:
|
||||
s.trackUtilityDieselEnabled = !s.trackUtilityDieselEnabled;
|
||||
PopoutModule.Persist();
|
||||
ForceRebuild();
|
||||
return true;
|
||||
case UICmd.ToggleUtilityLoaderLabels:
|
||||
s.trackUtilityLoaderEnabled = !s.trackUtilityLoaderEnabled;
|
||||
PopoutModule.Persist();
|
||||
ForceRebuild();
|
||||
return true;
|
||||
case UICmd.ToggleUtilityInterchangeLabels:
|
||||
s.trackUtilityInterchangeEnabled = !s.trackUtilityInterchangeEnabled;
|
||||
PopoutModule.Persist();
|
||||
ForceRebuild();
|
||||
return true;
|
||||
case UICmd.TrackLabelSetUtilityZoom:
|
||||
s.trackUtilityZoomLimit = Mathf.Clamp(y, 50f, 8000f);
|
||||
PopoutModule.Persist();
|
||||
return true;
|
||||
case UICmd.TrackLabelSetAllZoom:
|
||||
s.trackAllLabelsZoomLimit = Mathf.Clamp(y, 200f, 8000f);
|
||||
PopoutModule.Persist();
|
||||
return true;
|
||||
case UICmd.ToggleAvoidTrackLabels:
|
||||
s.trackLabelAvoidTrack = !s.trackLabelAvoidTrack;
|
||||
PopoutModule.Persist();
|
||||
return true;
|
||||
case UICmd.TrackLabelSetFontSizeMin:
|
||||
s.trackLabelFontSizeMin = Mathf.Clamp(y, 4f, s.trackLabelFontSize);
|
||||
PopoutModule.Persist();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Push(int handle, Camera? mapCamera)
|
||||
{
|
||||
try
|
||||
{
|
||||
PushInner(handle, mapCamera);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
S3.Core.Log.Error($"[popout] track labels push: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void PushInner(int handle, Camera? mapCamera)
|
||||
{
|
||||
if (mapCamera == null) return;
|
||||
if (!PopoutModule.Settings.trackLabelsEnabled) return;
|
||||
|
||||
var s = PopoutModule.Settings;
|
||||
bool shouldMerge = s.trackLabelMergeEnabled ||
|
||||
(mapCamera.orthographicSize > s.trackLabelMergeZoom);
|
||||
if (shouldMerge != _effectiveMergeEnabled)
|
||||
{
|
||||
_effectiveMergeEnabled = shouldMerge;
|
||||
ForceRebuild();
|
||||
}
|
||||
|
||||
_spawnPointTimer += Time.deltaTime;
|
||||
if (_spawnPointTimer >= 5f || _industryComponents == null)
|
||||
{
|
||||
RebuildTrackSpanLabels(mapCamera);
|
||||
_spawnPointTimer = 0f;
|
||||
}
|
||||
|
||||
int maxLabels = System.Math.Max(_trackSpanLabels.Count, _industryLabels.Count);
|
||||
if (_labelUs.Length < maxLabels)
|
||||
{
|
||||
_labelUs = new float[maxLabels];
|
||||
_labelVs = new float[maxLabels];
|
||||
_labelAngles = new float[maxLabels];
|
||||
_labelScales = new float[maxLabels];
|
||||
_anchorStarts = new int [maxLabels];
|
||||
_anchorCounts = new int [maxLabels];
|
||||
}
|
||||
if (_anchorUs.Length < _totalAnchorCount)
|
||||
{
|
||||
_anchorUs = new float[_totalAnchorCount];
|
||||
_anchorVs = new float[_totalAnchorCount];
|
||||
}
|
||||
|
||||
if (mapCamera.orthographicSize > s.trackAllLabelsZoomLimit)
|
||||
{
|
||||
if (_lastLabelCount != 0) Clear(handle);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mapCamera.orthographicSize > s.trackIndustryLabelZoom)
|
||||
{
|
||||
_labelNames.Clear();
|
||||
int icCount = 0;
|
||||
foreach (var (centroid, name, _) in _industryLabels)
|
||||
{
|
||||
Vector3 vp = mapCamera.WorldToViewportPoint(centroid.GameToWorld());
|
||||
if (vp.z < 0f || vp.x < -0.05f || vp.x > 1.05f ||
|
||||
vp.y < -0.05f || vp.y > 1.05f) continue;
|
||||
|
||||
if (icCount > 0) _labelNames.Append('\n');
|
||||
_labelNames.Append(name);
|
||||
_labelUs[icCount] = vp.x;
|
||||
_labelVs[icCount] = vp.y;
|
||||
_labelAngles[icCount] = 0f;
|
||||
_labelScales[icCount] = 1.5f;
|
||||
_anchorStarts[icCount] = 0;
|
||||
_anchorCounts[icCount] = 0;
|
||||
icCount++;
|
||||
}
|
||||
Native.RRPOPOUT_SetTrackLabels(handle, _labelNames.ToString(),
|
||||
_labelUs, _labelVs,
|
||||
s_emptyFloat, s_emptyFloat,
|
||||
_anchorStarts, _anchorCounts,
|
||||
_labelAngles, _labelScales, icCount);
|
||||
_lastLabelCount = icCount;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mapCamera.orthographicSize > s.trackLabelZoomLimit)
|
||||
{
|
||||
if (_lastLabelCount != 0) Clear(handle);
|
||||
return;
|
||||
}
|
||||
|
||||
_labelNames.Clear();
|
||||
int si = 0;
|
||||
int count = 0;
|
||||
int anchorOffset = 0;
|
||||
foreach (var (centroid, trackDir, anchors, name, utilityType) in _trackSpanLabels)
|
||||
{
|
||||
if (utilityType != 0 && mapCamera.orthographicSize > s.trackUtilityZoomLimit) { si++; continue; }
|
||||
|
||||
Vector3 labelCenter = si < _labelWorldOffsets.Length
|
||||
? centroid + _labelWorldOffsets[si]
|
||||
: centroid;
|
||||
|
||||
Vector3 vp = mapCamera.WorldToViewportPoint(labelCenter.GameToWorld());
|
||||
if (vp.z < 0f || vp.x < -0.05f || vp.x > 1.05f ||
|
||||
vp.y < -0.05f || vp.y > 1.05f) { si++; continue; }
|
||||
|
||||
if (count > 0) _labelNames.Append('\n');
|
||||
_labelNames.Append(name);
|
||||
_labelUs[count] = vp.x;
|
||||
_labelVs[count] = vp.y;
|
||||
float refZoom = Mathf.Max(s.trackLabelZoomLimit * 0.5f, 1f);
|
||||
float targetPx = s.trackLabelFontSize * (refZoom / mapCamera.orthographicSize);
|
||||
float clampedPx = Mathf.Clamp(targetPx, s.trackLabelFontSizeMin, s.trackLabelFontSize);
|
||||
_labelScales[count] = clampedPx / s.trackLabelFontSize;
|
||||
_anchorStarts[count] = anchorOffset;
|
||||
_anchorCounts[count] = anchors.Length;
|
||||
|
||||
Vector3 p0v = mapCamera.WorldToViewportPoint((centroid - trackDir * 20f).GameToWorld());
|
||||
Vector3 p1v = mapCamera.WorldToViewportPoint((centroid + trackDir * 20f).GameToWorld());
|
||||
float adx = p1v.x - p0v.x;
|
||||
float ady = -(p1v.y - p0v.y);
|
||||
float aspect = mapCamera.aspect > 0f ? mapCamera.aspect : 1f;
|
||||
float angle = Mathf.Atan2(ady / aspect, adx) * Mathf.Rad2Deg;
|
||||
if (angle > 90f) angle -= 180f;
|
||||
else if (angle < -90f) angle += 180f;
|
||||
_labelAngles[count] = angle;
|
||||
|
||||
foreach (var ap in anchors)
|
||||
{
|
||||
Vector3 av = mapCamera.WorldToViewportPoint(ap.GameToWorld());
|
||||
_anchorUs[anchorOffset] = av.x;
|
||||
_anchorVs[anchorOffset] = av.y;
|
||||
anchorOffset++;
|
||||
}
|
||||
si++;
|
||||
count++;
|
||||
}
|
||||
|
||||
Native.RRPOPOUT_SetTrackLabels(handle, _labelNames.ToString(),
|
||||
_labelUs, _labelVs,
|
||||
_anchorUs, _anchorVs,
|
||||
_anchorStarts, _anchorCounts,
|
||||
_labelAngles, _labelScales, count);
|
||||
_lastLabelCount = count;
|
||||
}
|
||||
|
||||
private static void ForceRebuild() => _spawnPointTimer = 99f;
|
||||
|
||||
private static void Clear(int handle)
|
||||
{
|
||||
Native.RRPOPOUT_SetTrackLabels(handle, "",
|
||||
s_emptyFloat, s_emptyFloat, s_emptyFloat, s_emptyFloat,
|
||||
s_emptyInt, s_emptyInt, s_emptyFloat, s_emptyFloat, 0);
|
||||
_lastLabelCount = 0;
|
||||
}
|
||||
|
||||
private static (Vector3 pos, Vector3 dir) FindStraightestNearMiddle(IList<Vector3> pts)
|
||||
{
|
||||
int n = pts.Count;
|
||||
Vector3 overallDir = (pts[n - 1] - pts[0]).normalized;
|
||||
if (n == 2) return ((pts[0] + pts[1]) * 0.5f, overallDir);
|
||||
|
||||
float totalLen = 0f;
|
||||
for (int k = 1; k < n; k++) totalLen += Vector3.Distance(pts[k], pts[k - 1]);
|
||||
if (totalLen < 0.01f) return (pts[n / 2], overallDir);
|
||||
|
||||
float midLen = totalLen * 0.5f;
|
||||
float bestScore = -1f;
|
||||
Vector3 bestPos = pts[n / 2];
|
||||
Vector3 bestDir = overallDir;
|
||||
float cumLen = 0f;
|
||||
|
||||
for (int k = 1; k < n; k++)
|
||||
{
|
||||
float segLen = Vector3.Distance(pts[k], pts[k - 1]);
|
||||
if (segLen < 0.01f) { cumLen += segLen; continue; }
|
||||
|
||||
float segMidLen = cumLen + segLen * 0.5f;
|
||||
cumLen += segLen;
|
||||
Vector3 segDir = (pts[k] - pts[k - 1]) / segLen;
|
||||
float straight = Mathf.Abs(Vector3.Dot(segDir, overallDir));
|
||||
float distFromMid = Mathf.Abs(segMidLen - midLen) / midLen;
|
||||
float score = straight * straight * (1f - distFromMid * 0.5f);
|
||||
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestPos = (pts[k] + pts[k - 1]) * 0.5f;
|
||||
bestDir = segDir;
|
||||
}
|
||||
}
|
||||
return (bestPos, bestDir);
|
||||
}
|
||||
|
||||
private static void RebuildTrackSpanLabels(Camera mapCamera)
|
||||
{
|
||||
_industryComponents = Object.FindObjectsOfType<IndustryComponent>();
|
||||
|
||||
var raw = new List<(Vector3 pos, Vector3 dir, string name, int utilityType)>();
|
||||
var seenSpans = new HashSet<TrackSpan>();
|
||||
var settings = PopoutModule.Settings;
|
||||
foreach (var ic in _industryComponents)
|
||||
{
|
||||
if (ic == null || !ic.IsVisible || ic.trackSpans.Length == 0) continue;
|
||||
string[] names = ExpandSpanNames(ic);
|
||||
for (int si = 0; si < ic.trackSpans.Length; si++)
|
||||
{
|
||||
var span = ic.trackSpans[si];
|
||||
if (!seenSpans.Add(span)) continue;
|
||||
var pts = span.GetPoints() as IList<Vector3>;
|
||||
Vector3 pos, dir;
|
||||
if (pts != null && pts.Count >= 2)
|
||||
(pos, dir) = FindStraightestNearMiddle(pts);
|
||||
else
|
||||
{
|
||||
pos = span.GetCenterPoint();
|
||||
dir = Vector3.right;
|
||||
}
|
||||
string spanName = NormalizeSpanName(si < names.Length ? names[si] : ic.DisplayName);
|
||||
int utType = GetUtilityType(spanName);
|
||||
if (utType == 1 && !settings.trackUtilityRepairEnabled) continue;
|
||||
if (utType == 2 && !settings.trackUtilityDieselEnabled) continue;
|
||||
if (utType == 3 && !settings.trackUtilityLoaderEnabled) continue;
|
||||
if (utType == 4 && !settings.trackUtilityInterchangeEnabled) continue;
|
||||
raw.Add((pos, dir, spanName, utType));
|
||||
}
|
||||
}
|
||||
|
||||
_trackSpanLabels.Clear();
|
||||
if (!_effectiveMergeEnabled)
|
||||
{
|
||||
foreach (var (pos, dir, name, utType) in raw)
|
||||
_trackSpanLabels.Add((pos, dir, new[] { pos }, name, utType));
|
||||
_totalAnchorCount = _trackSpanLabels.Count;
|
||||
RebuildIndustryLabels();
|
||||
ComputeWorldSpaceOffsets(mapCamera);
|
||||
return;
|
||||
}
|
||||
|
||||
var grouped = new Dictionary<string, List<(Vector3 pos, Vector3 dir, int utilityType)>>();
|
||||
foreach (var (pos, dir, name, utType) in raw)
|
||||
{
|
||||
if (!grouped.TryGetValue(name, out var list)) grouped[name] = list = new();
|
||||
list.Add((pos, dir, utType));
|
||||
}
|
||||
|
||||
foreach (var (name, entries) in grouped)
|
||||
{
|
||||
var assigned = new bool[entries.Count];
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
if (assigned[i]) continue;
|
||||
var cluster = new List<(Vector3 pos, Vector3 dir, int utilityType)> { entries[i] };
|
||||
assigned[i] = true;
|
||||
bool added;
|
||||
do {
|
||||
added = false;
|
||||
for (int j = i + 1; j < entries.Count; j++)
|
||||
{
|
||||
if (assigned[j]) continue;
|
||||
foreach (var (cp, _, _) in cluster)
|
||||
if (Vector3.Distance(entries[j].pos, cp) < kMergeDistGame)
|
||||
{ cluster.Add(entries[j]); assigned[j] = true; added = true; break; }
|
||||
}
|
||||
} while (added);
|
||||
|
||||
int clusterUtType = entries[0].utilityType;
|
||||
Vector3 centroid = Vector3.zero;
|
||||
Vector3 refDir = cluster[0].dir;
|
||||
Vector3 avgDir = Vector3.zero;
|
||||
var anchorPositions = new Vector3[cluster.Count];
|
||||
for (int k = 0; k < cluster.Count; k++)
|
||||
{
|
||||
centroid += cluster[k].pos;
|
||||
anchorPositions[k] = cluster[k].pos;
|
||||
var d = cluster[k].dir;
|
||||
avgDir += Vector3.Dot(d, refDir) >= 0f ? d : -d;
|
||||
}
|
||||
centroid /= cluster.Count;
|
||||
_trackSpanLabels.Add((centroid, avgDir.normalized, anchorPositions, name, clusterUtType));
|
||||
}
|
||||
}
|
||||
|
||||
_totalAnchorCount = 0;
|
||||
foreach (var (_, _, anchors, _, _) in _trackSpanLabels)
|
||||
_totalAnchorCount += anchors.Length;
|
||||
|
||||
RebuildIndustryLabels();
|
||||
ComputeWorldSpaceOffsets(mapCamera);
|
||||
}
|
||||
|
||||
private static void RebuildIndustryLabels()
|
||||
{
|
||||
_industryLabels.Clear();
|
||||
var byName = new Dictionary<string, (Vector3 sum, int count, int utilityType)>();
|
||||
var settings = PopoutModule.Settings;
|
||||
|
||||
foreach (var ic in _industryComponents)
|
||||
{
|
||||
if (ic == null || !ic.IsVisible || ic.trackSpans.Length == 0) continue;
|
||||
|
||||
string industryName = GetIndustryName(ic);
|
||||
int utType = GetUtilityType(industryName);
|
||||
if (utType == 1 && !settings.trackUtilityRepairEnabled) continue;
|
||||
if (utType == 2 && !settings.trackUtilityDieselEnabled) continue;
|
||||
if (utType == 3 && !settings.trackUtilityLoaderEnabled) continue;
|
||||
if (utType == 4 && !settings.trackUtilityInterchangeEnabled) continue;
|
||||
|
||||
Vector3 centroid = Vector3.zero;
|
||||
int n = 0;
|
||||
|
||||
foreach (var span in ic.trackSpans)
|
||||
{
|
||||
var pts = span.GetPoints() as IList<Vector3>;
|
||||
centroid += pts != null && pts.Count >= 2
|
||||
? FindStraightestNearMiddle(pts).pos
|
||||
: span.GetCenterPoint();
|
||||
n++;
|
||||
}
|
||||
if (n == 0) continue;
|
||||
centroid /= n;
|
||||
|
||||
if (byName.TryGetValue(industryName, out var entry))
|
||||
byName[industryName] = (entry.sum + centroid, entry.count + 1, utType);
|
||||
else
|
||||
byName[industryName] = (centroid, 1, utType);
|
||||
}
|
||||
|
||||
foreach (var (name, (sum, count, utType)) in byName)
|
||||
_industryLabels.Add((sum / count, name, utType));
|
||||
}
|
||||
|
||||
private static void ComputeWorldSpaceOffsets(Camera mapCamera)
|
||||
{
|
||||
int n = _trackSpanLabels.Count;
|
||||
if (n == 0) { _labelWorldOffsets = System.Array.Empty<Vector3>(); return; }
|
||||
|
||||
float screenH = mapCamera.pixelHeight > 0 ? mapCamera.pixelHeight : 1080f;
|
||||
float worldH = mapCamera.orthographicSize * 2f;
|
||||
float wpp = worldH / screenH;
|
||||
|
||||
var s = PopoutModule.Settings;
|
||||
float fH = s.trackLabelFontSize * wpp;
|
||||
float cW = fH * 0.55f;
|
||||
float pad = 3f * wpp;
|
||||
float gap = 4f * wpp;
|
||||
|
||||
var cx = new float[n];
|
||||
var cz = new float[n];
|
||||
var hw = new float[n];
|
||||
var hh = new float[n];
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var (centroid, trackDir, _, name, _) = _trackSpanLabels[i];
|
||||
|
||||
hw[i] = name.Length * cW * 0.5f + pad;
|
||||
hh[i] = fH * 0.5f + pad;
|
||||
|
||||
float lx = trackDir.x, lz = trackDir.z;
|
||||
float len = Mathf.Sqrt(lx * lx + lz * lz);
|
||||
if (len > 0.001f) { lx /= len; lz /= len; }
|
||||
float px = -lz, pz = lx;
|
||||
|
||||
float initOff = hh[i] + gap;
|
||||
cx[i] = centroid.x + px * initOff;
|
||||
cz[i] = centroid.z + pz * initOff;
|
||||
}
|
||||
|
||||
const int kMaxIter = 40;
|
||||
for (int iter = 0; iter < kMaxIter; iter++)
|
||||
{
|
||||
bool anyOverlap = false;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
for (int j = i + 1; j < n; j++)
|
||||
{
|
||||
float ox = (hw[i] + hw[j]) - Mathf.Abs(cx[i] - cx[j]);
|
||||
float oz = (hh[i] + hh[j]) - Mathf.Abs(cz[i] - cz[j]);
|
||||
if (ox <= 0f || oz <= 0f) continue;
|
||||
anyOverlap = true;
|
||||
float pushX = 0f, pushZ = 0f;
|
||||
if (ox < oz)
|
||||
pushX = ox * 0.55f * (cx[i] < cx[j] ? -1f : 1f);
|
||||
else
|
||||
pushZ = oz * 0.55f * (cz[i] < cz[j] ? -1f : 1f);
|
||||
cx[i] += pushX; cz[i] += pushZ;
|
||||
cx[j] -= pushX; cz[j] -= pushZ;
|
||||
}
|
||||
}
|
||||
if (!anyOverlap) break;
|
||||
}
|
||||
|
||||
if (_labelWorldOffsets.Length < n)
|
||||
_labelWorldOffsets = new Vector3[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var (centroid, _, _, _, _) = _trackSpanLabels[i];
|
||||
_labelWorldOffsets[i] = new Vector3(cx[i] - centroid.x, 0f, cz[i] - centroid.z);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetIndustryName(IndustryComponent ic)
|
||||
{
|
||||
string displayName = ic.DisplayName;
|
||||
|
||||
if (ic.trackSpans.Length > 1 && displayName.Contains('/'))
|
||||
{
|
||||
string first = displayName.Split('/')[0].Trim();
|
||||
int sp = first.LastIndexOf(' ');
|
||||
string baseName = sp >= 0 ? first.Substring(0, sp) : first;
|
||||
return NormalizeSpanName(baseName);
|
||||
}
|
||||
|
||||
int slash = displayName.IndexOf('/');
|
||||
string name = slash >= 0 ? displayName.Substring(0, slash).Trim() : displayName;
|
||||
name = NormalizeSpanName(name);
|
||||
int lastSp = name.LastIndexOf(' ');
|
||||
if (lastSp >= 0 && IsTrackCode(name.Substring(lastSp + 1)))
|
||||
return name.Substring(0, lastSp);
|
||||
return name;
|
||||
}
|
||||
|
||||
private static string NormalizeSpanName(string name)
|
||||
{
|
||||
int idx = name.IndexOf(" Interchange to ", System.StringComparison.OrdinalIgnoreCase);
|
||||
if (idx >= 0)
|
||||
return name.Substring(0, idx + " Interchange".Length);
|
||||
return name;
|
||||
}
|
||||
|
||||
private static int GetUtilityType(string name)
|
||||
{
|
||||
if (name.IndexOf(" Interchange", System.StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
name.StartsWith("Interchange", System.StringComparison.OrdinalIgnoreCase))
|
||||
return 4;
|
||||
if (name.EndsWith(" Repair Track", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
name.EndsWith(" Repair", System.StringComparison.OrdinalIgnoreCase))
|
||||
return 1;
|
||||
if (name.EndsWith(" Diesel Stand", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
name.EndsWith(" Diesel", System.StringComparison.OrdinalIgnoreCase))
|
||||
return 2;
|
||||
if (name.EndsWith(" Coal Loader", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
name.EndsWith(" Loader", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
name.EndsWith(" Coaling Tower", System.StringComparison.OrdinalIgnoreCase))
|
||||
return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool IsTrackCode(string s)
|
||||
{
|
||||
if (s.Length == 0 || s.Length > 4) return false;
|
||||
int i = 0;
|
||||
while (i < s.Length && char.IsLetter(s[i])) i++;
|
||||
if (i == 0 || i > 3) return false;
|
||||
while (i < s.Length && char.IsDigit(s[i])) i++;
|
||||
return i == s.Length;
|
||||
}
|
||||
|
||||
private static string[] ExpandSpanNames(IndustryComponent ic)
|
||||
{
|
||||
if (ic.trackSpans.Length <= 1) return new[] { ic.DisplayName };
|
||||
string[] parts = ic.DisplayName.Split('/');
|
||||
if (parts.Length != ic.trackSpans.Length) return new[] { ic.DisplayName };
|
||||
string first = parts[0].Trim();
|
||||
int sp = first.LastIndexOf(' ');
|
||||
string prefix = sp >= 0 ? first.Substring(0, sp + 1) : "";
|
||||
var result = new string[parts.Length];
|
||||
result[0] = first;
|
||||
for (int i = 1; i < parts.Length; i++)
|
||||
result[i] = prefix + parts[i].Trim();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
420
src/Modules/Popout/WaypointQueueBridge.cs
Normal file
420
src/Modules/Popout/WaypointQueueBridge.cs
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using Model;
|
||||
using Track;
|
||||
using UnityEngine;
|
||||
using UnityModManagerNet;
|
||||
|
||||
namespace S3.Modules.Popout;
|
||||
|
||||
/// <summary>
|
||||
/// Optional WaypointQueue integration via reflection — no compile-time reference.
|
||||
/// Silent no-op when the mod is not installed.
|
||||
/// </summary>
|
||||
internal static class WaypointQueueBridge
|
||||
{
|
||||
private static bool? _installed;
|
||||
|
||||
public static bool IsInstalled
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_installed.HasValue) return _installed.Value;
|
||||
_installed = Type.GetType("WaypointQueue.State.ModStateManager, WaypointQueue") != null
|
||||
|| FindMod("WaypointQueue") != null;
|
||||
return _installed.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetQueue(string locoId, out List<(Vector3 gamePos, bool active)> points)
|
||||
{
|
||||
points = new List<(Vector3, bool)>();
|
||||
if (!IsInstalled || string.IsNullOrEmpty(locoId)) return false;
|
||||
try
|
||||
{
|
||||
var mgrType = Type.GetType("WaypointQueue.State.ModStateManager, WaypointQueue");
|
||||
if (mgrType == null) return false;
|
||||
var shared = mgrType.GetProperty("Shared")?.GetValue(null);
|
||||
if (shared == null) return false;
|
||||
|
||||
object? state = Traverse.Create(shared).Method("GetLocoWaypointState", locoId).GetValue();
|
||||
if (state == null) return false;
|
||||
|
||||
var waypoints = Traverse.Create(state).Property("Waypoints").GetValue() as IEnumerable;
|
||||
if (waypoints == null) return false;
|
||||
|
||||
object? unresolved = Traverse.Create(state).Property("UnresolvedWaypoint").GetValue();
|
||||
string? activeId = unresolved != null
|
||||
? Traverse.Create(unresolved).Property<string>("Id").Value
|
||||
: null;
|
||||
|
||||
foreach (object wp in waypoints)
|
||||
{
|
||||
if (wp == null) continue;
|
||||
if (!TryWaypointPosition(wp, out Vector3 pos)) continue;
|
||||
string? id = null;
|
||||
try { id = Traverse.Create(wp).Property<string>("Id").Value; } catch { }
|
||||
bool active = !string.IsNullOrEmpty(activeId) && activeId == id;
|
||||
points.Add((pos, active));
|
||||
}
|
||||
|
||||
if (points.Count > 0 && !points.Exists(p => p.active))
|
||||
{
|
||||
var first = points[0];
|
||||
points[0] = (first.gamePos, true);
|
||||
}
|
||||
return points.Count > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WqWaypointSnap
|
||||
{
|
||||
public int Number;
|
||||
public string Id = "";
|
||||
public bool Active;
|
||||
public Vector3 Position;
|
||||
public Quaternion Rotation = Quaternion.identity;
|
||||
public bool HasPosition;
|
||||
public string CoupleToCarId = "";
|
||||
public string CouplingSearchMode = "";
|
||||
public string UncouplingMode = "";
|
||||
public int NumberOfCarsToCut;
|
||||
public string PostCouplingCutMode = "";
|
||||
public string UncouplingSearchResultCarId = "";
|
||||
public string CouplingSearchResultCarId = "";
|
||||
public bool CountFromNearest = true;
|
||||
public bool Pickup;
|
||||
public bool Dropoff;
|
||||
public string Name = "";
|
||||
public string Notes = "";
|
||||
public string AreaName = "";
|
||||
public bool WillWait;
|
||||
public int WaitMinutes;
|
||||
public bool WillRefuel;
|
||||
public string RefuelLoad = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed queue snapshot for car-card cut preview. Empty list if the loco has no queue.
|
||||
/// </summary>
|
||||
public static bool TryGetSnapshot(string locoId, out List<WqWaypointSnap> snaps)
|
||||
{
|
||||
snaps = new List<WqWaypointSnap>();
|
||||
if (!TryGetQueueObjects(locoId, out _, out List<object> waypoints, out string? unresolvedId))
|
||||
return false;
|
||||
for (int i = 0; i < waypoints.Count; i++)
|
||||
{
|
||||
object wp = waypoints[i];
|
||||
if (wp == null) continue;
|
||||
var s = new WqWaypointSnap { Number = i + 1 };
|
||||
s.Id = ReadStr(wp, "Id") ?? "";
|
||||
s.Active = !string.IsNullOrEmpty(unresolvedId) && unresolvedId == s.Id;
|
||||
s.HasPosition = TryWaypointPose(wp, out s.Position, out s.Rotation);
|
||||
s.CoupleToCarId = ReadStr(wp, "CoupleToCarId") ?? "";
|
||||
s.CouplingSearchMode = ReadEnum(wp, "CouplingSearchMode");
|
||||
s.UncouplingMode = ReadEnum(wp, "UncouplingMode");
|
||||
s.PostCouplingCutMode = ReadEnum(wp, "PostCouplingCutMode");
|
||||
s.UncouplingSearchResultCarId = ReadStr(wp, "UncouplingSearchResultCarId") ?? "";
|
||||
s.CouplingSearchResultCarId = ReadStr(wp, "CouplingSearchResultCarId") ?? "";
|
||||
s.NumberOfCarsToCut = ReadInt(wp, "NumberOfCarsToCut");
|
||||
s.CountFromNearest = ReadBool(wp, "CountUncoupledFromNearestToWaypoint", true);
|
||||
s.Pickup = ReadBool(wp, "WillPostCoupleCutPickup", false);
|
||||
s.Dropoff = ReadBool(wp, "WillPostCoupleCutDropoff", false);
|
||||
s.Name = ReadStr(wp, "Name") ?? "";
|
||||
s.Notes = ReadStr(wp, "Notes") ?? "";
|
||||
s.AreaName = ReadStr(wp, "AreaName") ?? "";
|
||||
s.WillWait = ReadBool(wp, "WillWait", false);
|
||||
s.WaitMinutes = ReadInt(wp, "WaitForDurationMinutes");
|
||||
s.WillRefuel = ReadBool(wp, "WillRefuel", false);
|
||||
s.RefuelLoad = ReadStr(wp, "RefuelLoadName") ?? "";
|
||||
if (!s.Pickup && string.Equals(s.PostCouplingCutMode, "Pickup", StringComparison.OrdinalIgnoreCase))
|
||||
s.Pickup = Coupling(s);
|
||||
if (!s.Dropoff && string.Equals(s.PostCouplingCutMode, "Dropoff", StringComparison.OrdinalIgnoreCase))
|
||||
s.Dropoff = Coupling(s);
|
||||
snaps.Add(s);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool Coupling(WqWaypointSnap s) =>
|
||||
!string.IsNullOrEmpty(s.CoupleToCarId)
|
||||
|| string.Equals(s.CouplingSearchMode, "Nearest", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(s.CouplingSearchMode, "SpecificCar", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
static string? ReadStr(object wp, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
object? v = Traverse.Create(wp).Property(name).GetValue();
|
||||
return v as string ?? v?.ToString();
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
static string ReadEnum(object wp, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
object? v = Traverse.Create(wp).Property(name).GetValue();
|
||||
return v?.ToString() ?? "";
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
static int ReadInt(object wp, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
object? v = Traverse.Create(wp).Property(name).GetValue();
|
||||
if (v is int i) return i;
|
||||
if (v is long l) return (int)l;
|
||||
if (v != null) return Convert.ToInt32(v);
|
||||
}
|
||||
catch { }
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool ReadBool(object wp, string name, bool fallback)
|
||||
{
|
||||
try
|
||||
{
|
||||
object? v = Traverse.Create(wp).Property(name).GetValue();
|
||||
if (v is bool b) return b;
|
||||
}
|
||||
catch { }
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raw WQ state for debug dumps. waypoints is empty if the loco has no queue.
|
||||
/// </summary>
|
||||
public static bool TryGetQueueObjects(
|
||||
string locoId,
|
||||
out object? state,
|
||||
out List<object> waypoints,
|
||||
out string? unresolvedId)
|
||||
{
|
||||
state = null;
|
||||
waypoints = new List<object>();
|
||||
unresolvedId = null;
|
||||
if (!IsInstalled || string.IsNullOrEmpty(locoId)) return false;
|
||||
try
|
||||
{
|
||||
var mgrType = Type.GetType("WaypointQueue.State.ModStateManager, WaypointQueue");
|
||||
if (mgrType == null) return false;
|
||||
var shared = mgrType.GetProperty("Shared")?.GetValue(null);
|
||||
if (shared == null) return false;
|
||||
|
||||
state = Traverse.Create(shared).Method("GetLocoWaypointState", locoId).GetValue();
|
||||
if (state == null) return true;
|
||||
|
||||
object? unresolved = Traverse.Create(state).Property("UnresolvedWaypoint").GetValue();
|
||||
if (unresolved != null)
|
||||
{
|
||||
try { unresolvedId = Traverse.Create(unresolved).Property<string>("Id").Value; }
|
||||
catch { }
|
||||
}
|
||||
|
||||
if (Traverse.Create(state).Property("Waypoints").GetValue() is IEnumerable list)
|
||||
{
|
||||
foreach (object wp in list)
|
||||
if (wp != null) waypoints.Add(wp);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryWaypointPosition(object wp, out Vector3 pos) =>
|
||||
TryWaypointPose(wp, out pos, out _);
|
||||
|
||||
private static bool TryWaypointPose(object wp, out Vector3 pos, out Quaternion rot)
|
||||
{
|
||||
pos = default;
|
||||
rot = Quaternion.identity;
|
||||
try
|
||||
{
|
||||
object? locObj = Traverse.Create(wp).Property("Location").GetValue();
|
||||
if (locObj is Location loc && loc.IsValid)
|
||||
{
|
||||
pos = loc.GetPosition();
|
||||
rot = loc.GetRotation();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
string? locStr = Traverse.Create(wp).Property<string>("LocationString").Value;
|
||||
if (string.IsNullOrEmpty(locStr) || Graph.Shared == null) return false;
|
||||
Location loc2 = Graph.Shared.ResolveLocationString(locStr);
|
||||
if (!loc2.IsValid) return false;
|
||||
pos = loc2.GetPosition();
|
||||
rot = loc2.GetRotation();
|
||||
return true;
|
||||
}
|
||||
catch { }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append a waypoint to the loco's WQ list. Does not call vanilla SetWaypoint
|
||||
/// (that Harmony prefix can wipe the queue). Returns the last waypoint object
|
||||
/// so callers can set couple/cut/pickup fields.
|
||||
/// </summary>
|
||||
public static bool TryAppend(BaseLocomotive loco, Location location, string? coupleToCarId, out object? waypoint)
|
||||
{
|
||||
waypoint = null;
|
||||
if (!IsInstalled || loco == null) return false;
|
||||
try
|
||||
{
|
||||
var ctrlType = Type.GetType("WaypointQueue.WaypointQueueController, WaypointQueue");
|
||||
if (ctrlType == null) return false;
|
||||
object? shared = ctrlType.GetProperty("Shared")?.GetValue(null);
|
||||
if (shared == null) return false;
|
||||
|
||||
string couple = coupleToCarId ?? "";
|
||||
Traverse.Create(shared).Method("AddWaypoint", loco, location, couple, false, false).GetValue();
|
||||
waypoint = LastWaypoint(loco.id);
|
||||
return waypoint != null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
S3.Core.Log.Warn($"[radio] WQ append failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void ApplyCut(object waypoint, string? specificCarId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = Traverse.Create(waypoint);
|
||||
if (!string.IsNullOrEmpty(specificCarId) &&
|
||||
TryEnum(waypoint, "UncoupleMode", "BySpecificCar", out object byCar))
|
||||
{
|
||||
t.Property("UncouplingMode").SetValue(byCar);
|
||||
t.Property("UncouplingSearchResultCarId").SetValue(specificCarId);
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyUncoupleByCount(waypoint, 1);
|
||||
return;
|
||||
}
|
||||
Persist(waypoint);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
S3.Core.Log.Warn($"[radio] WQ cut fields failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Couple, then pick up N cars. WQ clears Pickup if NumberOfCarsToCut is 0.
|
||||
/// </summary>
|
||||
public static void ApplyPickup(object waypoint, int count)
|
||||
=> ApplyPostCoupleCut(waypoint, "Pickup", count);
|
||||
|
||||
/// <summary>
|
||||
/// Couple, then drop off N cars. WQ clears Dropoff if NumberOfCarsToCut is 0.
|
||||
/// </summary>
|
||||
public static void ApplyDropoff(object waypoint, int count)
|
||||
=> ApplyPostCoupleCut(waypoint, "Dropoff", count);
|
||||
|
||||
/// <summary>
|
||||
/// Uncouple N cars at this waypoint with no coupling order (spot / drop on track).
|
||||
/// </summary>
|
||||
public static void ApplyUncoupleByCount(object waypoint, int count)
|
||||
{
|
||||
try
|
||||
{
|
||||
int n = Math.Max(1, count);
|
||||
var t = Traverse.Create(waypoint);
|
||||
t.Property("NumberOfCarsToCut").SetValue(n);
|
||||
if (TryEnum(waypoint, "UncoupleMode", "ByCount", out object byCount))
|
||||
t.Property("UncouplingMode").SetValue(byCount);
|
||||
Persist(waypoint);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
S3.Core.Log.Warn($"[radio] WQ uncouple-by-count failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyPostCoupleCut(object waypoint, string pickupOrDropoff, int count)
|
||||
{
|
||||
try
|
||||
{
|
||||
int n = Math.Max(1, count);
|
||||
var t = Traverse.Create(waypoint);
|
||||
// Count first — WQ zeros the post-couple mode when the count is still 0.
|
||||
t.Property("NumberOfCarsToCut").SetValue(n);
|
||||
if (TryEnum(waypoint, "PostCoupleCutType", pickupOrDropoff, out object cut))
|
||||
t.Property("PostCouplingCutMode").SetValue(cut);
|
||||
if (TryEnum(waypoint, "UncoupleMode", "ByCount", out object byCount))
|
||||
t.Property("UncouplingMode").SetValue(byCount);
|
||||
Persist(waypoint);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
S3.Core.Log.Warn($"[radio] WQ {pickupOrDropoff} fields failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryEnum(object waypoint, string nested, string name, out object value)
|
||||
{
|
||||
value = null!;
|
||||
Type wpType = waypoint.GetType();
|
||||
Type? t = wpType.GetNestedType(nested)
|
||||
?? Type.GetType($"WaypointQueue.ManagedWaypoint+{nested}, WaypointQueue");
|
||||
if (t == null || !Enum.IsDefined(t, name)) return false;
|
||||
value = Enum.Parse(t, name);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static object? LastWaypoint(string locoId)
|
||||
{
|
||||
var mgrType = Type.GetType("WaypointQueue.State.ModStateManager, WaypointQueue");
|
||||
object? shared = mgrType?.GetProperty("Shared")?.GetValue(null);
|
||||
if (shared == null) return null;
|
||||
object? state = Traverse.Create(shared).Method("GetLocoWaypointState", locoId).GetValue();
|
||||
if (state == null) return null;
|
||||
if (Traverse.Create(state).Property("Waypoints").GetValue() is not IList list || list.Count == 0)
|
||||
return null;
|
||||
return list[list.Count - 1];
|
||||
}
|
||||
|
||||
private static void Persist(object waypoint)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ctrlType = Type.GetType("WaypointQueue.WaypointQueueController, WaypointQueue");
|
||||
object? shared = ctrlType?.GetProperty("Shared")?.GetValue(null);
|
||||
if (shared == null) return;
|
||||
Traverse.Create(shared).Method("UpdateWaypoint", waypoint).GetValue();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static UnityModManager.ModEntry? FindMod(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var m in UnityModManager.modEntries)
|
||||
if (m.Info.Id == id) return m;
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
243
src/Modules/Popout/WqDumpCommand.cs
Normal file
243
src/Modules/Popout/WqDumpCommand.cs
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using HarmonyLib;
|
||||
using Model;
|
||||
using Model.AI;
|
||||
using S3.Core;
|
||||
using UI.Console;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.Popout;
|
||||
|
||||
[HarmonyPatch(typeof(ConsoleCommandHandler))]
|
||||
[HarmonyPatch("_HandleSlashCommand")]
|
||||
static class WqDumpCommandPatch
|
||||
{
|
||||
static bool Prefix(string[] comps, ref string __result)
|
||||
{
|
||||
if (comps.Length == 0 || !string.Equals(comps[0], "/s3wq", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
__result = WqDumpCommand.Handle(comps);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static class WqDumpCommand
|
||||
{
|
||||
public static void Install()
|
||||
{
|
||||
try
|
||||
{
|
||||
var h = new Harmony("S3.wqdump");
|
||||
h.CreateClassProcessor(typeof(WqDumpCommandPatch)).Patch();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error($"[wqdump] patch failed: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal static string Handle(string[] comps)
|
||||
{
|
||||
if (comps.Length >= 2)
|
||||
{
|
||||
string sub = comps[1].ToLowerInvariant();
|
||||
if (sub == "help") return Usage();
|
||||
if (sub != "dump") return $"Unknown subcommand '{comps[1]}'. {Usage()}";
|
||||
}
|
||||
|
||||
return Dump();
|
||||
}
|
||||
|
||||
static string Usage() =>
|
||||
"Usage: /s3wq dump (selected consist loco; writes Mods/S3/wq-dump.txt)";
|
||||
|
||||
static string Dump()
|
||||
{
|
||||
if (!WaypointQueueBridge.IsInstalled)
|
||||
return "WaypointQueue is not installed.";
|
||||
|
||||
Car? seed = null;
|
||||
try { seed = TrainController.Shared?.SelectedCar; }
|
||||
catch { }
|
||||
if (seed == null)
|
||||
return "No car selected.";
|
||||
|
||||
BaseLocomotive? loco = FindLoco(seed);
|
||||
if (loco == null)
|
||||
return $"No locomotive on consist of {seed.DisplayName} ({seed.id}).";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"=== S3 WQ dump {DateTime.Now:yyyy-MM-dd HH:mm:ss} ===");
|
||||
sb.AppendLine($"Selected: {seed.DisplayName} id={seed.id}");
|
||||
sb.AppendLine($"Loco: {loco.DisplayName} id={loco.id}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("-- consist (EnumerateCoupled) --");
|
||||
int i = 0;
|
||||
try
|
||||
{
|
||||
foreach (Car c in seed.EnumerateCoupled())
|
||||
{
|
||||
if (c == null) continue;
|
||||
string kind = c is BaseLocomotive ? "loco" : "car";
|
||||
sb.AppendLine($" [{i++}] {kind} {c.DisplayName} id={c.id}");
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
sb.AppendLine($" EnumerateCoupled failed: {e.Message}");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
DumpVanillaWaypoint(sb, loco);
|
||||
|
||||
if (!WaypointQueueBridge.TryGetQueueObjects(loco.id, out object? state, out List<object> waypoints, out string? unresolvedId))
|
||||
{
|
||||
sb.AppendLine("Failed to read WaypointQueue state (reflection).");
|
||||
return Finish(sb);
|
||||
}
|
||||
|
||||
sb.AppendLine($"-- WaypointQueue unresolvedId={unresolvedId ?? "(none)"} count={waypoints.Count} --");
|
||||
if (state != null)
|
||||
sb.AppendLine($" state type: {state.GetType().FullName}");
|
||||
if (waypoints.Count == 0)
|
||||
sb.AppendLine(" (empty queue)");
|
||||
|
||||
for (int n = 0; n < waypoints.Count; n++)
|
||||
{
|
||||
object wp = waypoints[n];
|
||||
string? id = PropStr(wp, "Id");
|
||||
bool active = !string.IsNullOrEmpty(unresolvedId) && unresolvedId == id;
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($" waypoint {n + 1}{(active ? " [ACTIVE]" : "")} type={wp.GetType().FullName}");
|
||||
DumpObject(sb, wp, " ");
|
||||
}
|
||||
|
||||
return Finish(sb);
|
||||
}
|
||||
|
||||
static void DumpVanillaWaypoint(StringBuilder sb, BaseLocomotive loco)
|
||||
{
|
||||
sb.AppendLine("-- vanilla AE Orders.Waypoint --");
|
||||
try
|
||||
{
|
||||
var planner = loco.AutoEngineerPlanner;
|
||||
if (planner == null)
|
||||
{
|
||||
sb.AppendLine(" (no AutoEngineerPlanner)");
|
||||
return;
|
||||
}
|
||||
object? raw = Traverse.Create(planner).Field("_orders").GetValue();
|
||||
if (raw is not Orders orders)
|
||||
{
|
||||
sb.AppendLine(" (no Orders)");
|
||||
return;
|
||||
}
|
||||
sb.AppendLine($" Mode={orders.Mode}");
|
||||
OrderWaypoint? maybe = orders.Waypoint;
|
||||
if (!maybe.HasValue)
|
||||
{
|
||||
sb.AppendLine(" Waypoint=null");
|
||||
return;
|
||||
}
|
||||
OrderWaypoint vwp = maybe.Value;
|
||||
sb.AppendLine($" LocationString={vwp.LocationString}");
|
||||
sb.AppendLine($" CoupleToCarId={vwp.CoupleToCarId}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
sb.AppendLine($" {e.Message}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
static void DumpObject(StringBuilder sb, object obj, string pad)
|
||||
{
|
||||
PropertyInfo[] props;
|
||||
try { props = obj.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance); }
|
||||
catch (Exception e)
|
||||
{
|
||||
sb.AppendLine($"{pad}(properties failed: {e.Message})");
|
||||
return;
|
||||
}
|
||||
|
||||
Array.Sort(props, (a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
|
||||
foreach (PropertyInfo p in props)
|
||||
{
|
||||
if (p.GetIndexParameters().Length > 0) continue;
|
||||
try
|
||||
{
|
||||
object? val = p.GetValue(obj, null);
|
||||
sb.AppendLine($"{pad}{p.Name} = {Fmt(val)}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
sb.AppendLine($"{pad}{p.Name} = <{e.GetType().Name}: {e.Message}>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static string Fmt(object? val)
|
||||
{
|
||||
if (val == null) return "null";
|
||||
if (val is string s) return s.Length == 0 ? "\"\"" : s;
|
||||
if (val is Car car) return $"Car({car.DisplayName} id={car.id})";
|
||||
if (val is BaseLocomotive loco) return $"Loco({loco.DisplayName} id={loco.id})";
|
||||
Type t = val.GetType();
|
||||
if (t.IsEnum) return val.ToString() ?? "";
|
||||
if (val is Vector3 v) return $"({v.x:F1},{v.y:F1},{v.z:F1})";
|
||||
string text = val.ToString() ?? "";
|
||||
if (text.Length > 240) return text.Substring(0, 240) + "...";
|
||||
return text;
|
||||
}
|
||||
|
||||
static string? PropStr(object obj, string name)
|
||||
{
|
||||
try { return Traverse.Create(obj).Property<string>(name).Value; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
static BaseLocomotive? FindLoco(Car seed)
|
||||
{
|
||||
if (seed is BaseLocomotive self) return self;
|
||||
BaseLocomotive? first = null;
|
||||
try
|
||||
{
|
||||
foreach (Car c in seed.EnumerateCoupled())
|
||||
{
|
||||
if (c is not BaseLocomotive loco) continue;
|
||||
first ??= loco;
|
||||
bool mu = false;
|
||||
try { mu = Traverse.Create(loco).Property<bool>("IsMuEnabled").Value; }
|
||||
catch { }
|
||||
if (!mu) return loco;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return first;
|
||||
}
|
||||
|
||||
static string Finish(StringBuilder sb)
|
||||
{
|
||||
string text = sb.ToString();
|
||||
try { Log.Info("[wqdump]\n" + text); }
|
||||
catch { }
|
||||
|
||||
string path = "(not written)";
|
||||
try
|
||||
{
|
||||
string dir = Main.ModEntry.Path;
|
||||
path = Path.Combine(dir, "wq-dump.txt");
|
||||
File.WriteAllText(path, text);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return text + $"\nFailed to write file: {e.Message}";
|
||||
}
|
||||
|
||||
return $"Wrote {path}\n(also in the S3 log)\n\n" + text;
|
||||
}
|
||||
}
|
||||
1100
src/Modules/Profiler/AutomatedBenchmark.cs
Normal file
1100
src/Modules/Profiler/AutomatedBenchmark.cs
Normal file
File diff suppressed because it is too large
Load diff
63
src/Modules/Profiler/HitchFrameDriver.cs
Normal file
63
src/Modules/Profiler/HitchFrameDriver.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using System.Diagnostics;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace S3.Modules.Profiler;
|
||||
|
||||
[DefaultExecutionOrder(-32000)]
|
||||
public sealed class HitchFrameDriver : MonoBehaviour
|
||||
{
|
||||
internal static long FrameStartTicks;
|
||||
internal static long LateTicks;
|
||||
long _renderStartTicks;
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
RenderPipelineManager.beginFrameRendering += BeginFrameRendering;
|
||||
RenderPipelineManager.endFrameRendering += EndFrameRendering;
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
RenderPipelineManager.beginFrameRendering -= BeginFrameRendering;
|
||||
RenderPipelineManager.endFrameRendering -= EndFrameRendering;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
HitchSampler.AdvanceFrame(Time.unscaledDeltaTime * 1000f);
|
||||
FrameStartTicks = Stopwatch.GetTimestamp();
|
||||
LateTicks = 0;
|
||||
}
|
||||
|
||||
void BeginFrameRendering(ScriptableRenderContext _, Camera[] __)
|
||||
{
|
||||
if (!HitchSampler.Active) return;
|
||||
long now = Stopwatch.GetTimestamp();
|
||||
long boundary = LateTicks != 0 ? LateTicks : FrameStartTicks;
|
||||
if (boundary != 0)
|
||||
HitchSampler.Record("phase.pre_render_gap", now - boundary);
|
||||
_renderStartTicks = now;
|
||||
}
|
||||
|
||||
void EndFrameRendering(ScriptableRenderContext _, Camera[] __)
|
||||
{
|
||||
if (!HitchSampler.Active || _renderStartTicks == 0) return;
|
||||
HitchSampler.Record(
|
||||
"phase.render_pipeline",
|
||||
Stopwatch.GetTimestamp() - _renderStartTicks);
|
||||
_renderStartTicks = 0;
|
||||
}
|
||||
}
|
||||
|
||||
[DefaultExecutionOrder(32000)]
|
||||
public sealed class HitchLateFrameDriver : MonoBehaviour
|
||||
{
|
||||
void LateUpdate()
|
||||
{
|
||||
if (!HitchSampler.Active || HitchFrameDriver.FrameStartTicks == 0) return;
|
||||
long now = Stopwatch.GetTimestamp();
|
||||
HitchSampler.Record("phase.scripts_to_late", now - HitchFrameDriver.FrameStartTicks);
|
||||
HitchFrameDriver.LateTicks = now;
|
||||
}
|
||||
}
|
||||
140
src/Modules/Profiler/HitchProbePatches.cs
Normal file
140
src/Modules/Profiler/HitchProbePatches.cs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using HarmonyLib;
|
||||
using S3.Core;
|
||||
|
||||
namespace S3.Modules.Profiler;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves vanilla targets at runtime so game-version method drift degrades telemetry rather
|
||||
/// than preventing the Profiler module from loading.
|
||||
/// </summary>
|
||||
public static class HitchProbePatches
|
||||
{
|
||||
readonly struct Target
|
||||
{
|
||||
public readonly string Type;
|
||||
public readonly string Method;
|
||||
public readonly string Id;
|
||||
|
||||
public Target(string type, string method, string id)
|
||||
{
|
||||
Type = type;
|
||||
Method = method;
|
||||
Id = id;
|
||||
}
|
||||
}
|
||||
|
||||
static readonly Target[] Targets =
|
||||
{
|
||||
new("RollingStock.CarCuller", "Update", "cars.culler_update"),
|
||||
new("RollingStock.CarCuller", "ProcessPending", "cars.process_pending"),
|
||||
new("RollingStock.CarCuller", "OnCarCullingGroupStateChanged", "cars.cull_transition"),
|
||||
new("Model.Car", "ModelLoadRetain", "cars.model_retain"),
|
||||
new("Model.Car", "HandleModelsLoaded", "cars.model_loaded"),
|
||||
new("Model.Car", "UnloadModels", "cars.model_unload"),
|
||||
new("Model.Car", "SetCullerDistanceBand", "cars.distance_band"),
|
||||
new("Model.Car", "PositionWheelBoundsFront", "cars.position_wheels"),
|
||||
new("Helpers.SceneryAssetInstance", "SetLoaded", "scenery.set_loaded"),
|
||||
new("Helpers.SceneryAssetInstance", "DidLoadModel", "scenery.did_load_model"),
|
||||
new("Helpers.SceneryAssetInstance", "CullingSphereStateChanged", "scenery.cull_transition"),
|
||||
new("Helpers.Culling.CullingManager", "Update", "culling.update"),
|
||||
new("Helpers.Culling.CullingManager", "FixedUpdate", "culling.fixed_update"),
|
||||
new("Cameras.StrategyCameraController", "Update", "camera.strategy_update"),
|
||||
new("Cameras.StrategyCameraController", "UpdateCameraPosition", "camera.update_position"),
|
||||
new("Cameras.StrategyCameraController", "FindGround", "camera.find_ground"),
|
||||
new("TrainController", "FixedUpdate", "train.fixed_update"),
|
||||
new("TrainController", "CarDidPosition", "train.car_did_position"),
|
||||
new("WorldStreamer2.Streamer", "Update", "streamer.update"),
|
||||
new("WorldStreamer2.Streamer", "CheckPositionTiles", "streamer.check_tiles"),
|
||||
new("WorldStreamer2.Streamer", "LoadLevelAsyncManage", "streamer.load_pump"),
|
||||
new("WorldStreamer2.Streamer", "SceneLoading", "streamer.scene_loading"),
|
||||
new("WorldStreamer2.Streamer", "SceneUnloading", "streamer.scene_unloading"),
|
||||
new("WorldStreamer2.StreamerLoadingManager", "Update", "streamer.manager_update"),
|
||||
new("WorldStreamer2.TerrainCullingSystem", "Update", "terrain.culling_update"),
|
||||
new("WorldStreamer2.TerrainCullingSystem", "CheckVisibility", "terrain.check_visibility"),
|
||||
new("WorldStreamer2.PhysicCullingSystem", "Update", "physics_culling.update"),
|
||||
new("WorldStreamer2.PhysicCullingSystem", "CheckVisibility", "physics_culling.check_visibility"),
|
||||
};
|
||||
|
||||
static readonly Dictionary<MethodBase, string> ProbeIds = new();
|
||||
|
||||
public static void Install(Harmony harmony)
|
||||
{
|
||||
ProbeIds.Clear();
|
||||
var prefix = new HarmonyMethod(typeof(HitchProbePatches), nameof(Prefix));
|
||||
var postfix = new HarmonyMethod(typeof(HitchProbePatches), nameof(Postfix));
|
||||
int patched = 0;
|
||||
int missed = 0;
|
||||
|
||||
foreach (Target target in Targets)
|
||||
{
|
||||
Type? type = FindType(target.Type);
|
||||
if (type == null)
|
||||
{
|
||||
Log.Warn($"[profiler] Hitch probe type missing: {target.Type}");
|
||||
missed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
bool found = false;
|
||||
foreach (MethodInfo method in AccessTools.GetDeclaredMethods(type))
|
||||
{
|
||||
if (method.Name != target.Method || method.IsAbstract || method.ContainsGenericParameters)
|
||||
continue;
|
||||
found = true;
|
||||
if (ProbeIds.ContainsKey(method)) continue;
|
||||
try
|
||||
{
|
||||
ProbeIds[method] = target.Id;
|
||||
harmony.Patch(method, prefix, postfix);
|
||||
patched++;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ProbeIds.Remove(method);
|
||||
Log.Warn($"[profiler] Hitch probe failed: {type.FullName}.{method.Name}: {e.Message}");
|
||||
missed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
Log.Warn($"[profiler] Hitch probe method missing: {target.Type}.{target.Method}");
|
||||
missed++;
|
||||
}
|
||||
}
|
||||
|
||||
Log.Info($"[profiler] Hitch probes installed: {patched}; unavailable: {missed}.");
|
||||
}
|
||||
|
||||
public static void Prefix(MethodBase __originalMethod, out long __state)
|
||||
{
|
||||
__state = HitchSampler.Active && ProbeIds.ContainsKey(__originalMethod)
|
||||
? Stopwatch.GetTimestamp()
|
||||
: 0;
|
||||
}
|
||||
|
||||
public static void Postfix(MethodBase __originalMethod, long __state)
|
||||
{
|
||||
if (__state == 0 || !HitchSampler.Active) return;
|
||||
if (ProbeIds.TryGetValue(__originalMethod, out string? id))
|
||||
HitchSampler.Record(id, Stopwatch.GetTimestamp() - __state);
|
||||
}
|
||||
|
||||
static Type? FindType(string fullName)
|
||||
{
|
||||
foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try
|
||||
{
|
||||
Type? type = assembly.GetType(fullName, false);
|
||||
if (type != null) return type;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
163
src/Modules/Profiler/HitchSampler.cs
Normal file
163
src/Modules/Profiler/HitchSampler.cs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.Profiler;
|
||||
|
||||
public sealed class HitchProbeSample
|
||||
{
|
||||
public int Calls;
|
||||
public long Ticks;
|
||||
public long MaxTicks;
|
||||
|
||||
public double TotalMs => HitchSampler.TicksToMs(Ticks);
|
||||
public double MaxMs => HitchSampler.TicksToMs(MaxTicks);
|
||||
}
|
||||
|
||||
public sealed class HitchFrameRecord
|
||||
{
|
||||
public string Phase = "";
|
||||
public int Frame;
|
||||
public float FrameMs;
|
||||
public int Gc0;
|
||||
public int Gc1;
|
||||
public int Gc2;
|
||||
public long MonoDelta;
|
||||
public Vector3 CameraPosition;
|
||||
public readonly Dictionary<string, HitchProbeSample> Probes =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public bool IsHitch(float thresholdMs) => FrameMs >= thresholdMs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Low-overhead, main-thread frame aggregator for direct Harmony and render-pipeline timings.
|
||||
/// Accumulators are committed at the beginning of the following Unity frame so LateUpdate and
|
||||
/// render callbacks are included in the frame whose unscaled delta is being reported.
|
||||
/// </summary>
|
||||
public static class HitchSampler
|
||||
{
|
||||
static readonly Dictionary<string, HitchProbeSample> Current =
|
||||
new(StringComparer.Ordinal);
|
||||
static readonly List<HitchFrameRecord> Frames = new(1024);
|
||||
|
||||
static string _phase = "";
|
||||
static int _frame;
|
||||
static int _gc0;
|
||||
static int _gc1;
|
||||
static int _gc2;
|
||||
static long _mono;
|
||||
static bool _hasPendingFrame;
|
||||
|
||||
public static bool Active { get; private set; }
|
||||
public static float HitchThresholdMs { get; private set; } = 100f;
|
||||
|
||||
public static void BeginCapture(string phase, float hitchThresholdMs)
|
||||
{
|
||||
Current.Clear();
|
||||
Frames.Clear();
|
||||
_phase = phase;
|
||||
_frame = 0;
|
||||
HitchThresholdMs = Mathf.Clamp(hitchThresholdMs, 16.7f, 1000f);
|
||||
SnapshotGc();
|
||||
_hasPendingFrame = false;
|
||||
Active = true;
|
||||
}
|
||||
|
||||
public static List<HitchFrameRecord> EndCapture()
|
||||
{
|
||||
Active = false;
|
||||
_hasPendingFrame = false;
|
||||
Current.Clear();
|
||||
return new List<HitchFrameRecord>(Frames);
|
||||
}
|
||||
|
||||
public static void CancelCapture()
|
||||
{
|
||||
Active = false;
|
||||
_hasPendingFrame = false;
|
||||
Current.Clear();
|
||||
Frames.Clear();
|
||||
}
|
||||
|
||||
public static void Record(string id, long ticks, int calls = 1)
|
||||
{
|
||||
if (!Active || ticks < 0) return;
|
||||
if (!Current.TryGetValue(id, out HitchProbeSample? sample))
|
||||
{
|
||||
sample = new HitchProbeSample();
|
||||
Current[id] = sample;
|
||||
}
|
||||
sample.Calls += calls;
|
||||
sample.Ticks += ticks;
|
||||
if (ticks > sample.MaxTicks) sample.MaxTicks = ticks;
|
||||
}
|
||||
|
||||
public static void Count(string id, int calls = 1)
|
||||
{
|
||||
if (!Active || calls <= 0) return;
|
||||
if (!Current.TryGetValue(id, out HitchProbeSample? sample))
|
||||
{
|
||||
sample = new HitchProbeSample();
|
||||
Current[id] = sample;
|
||||
}
|
||||
sample.Calls += calls;
|
||||
}
|
||||
|
||||
/// <summary>Called at the first Update of each frame by <see cref="HitchFrameDriver"/>.</summary>
|
||||
public static void AdvanceFrame(float frameMs)
|
||||
{
|
||||
if (!Active) return;
|
||||
|
||||
int gc0 = GC.CollectionCount(0);
|
||||
int gc1 = GC.CollectionCount(1);
|
||||
int gc2 = GC.CollectionCount(2);
|
||||
long mono = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
|
||||
|
||||
if (_hasPendingFrame && frameMs > 0f && frameMs < 2000f)
|
||||
{
|
||||
var record = new HitchFrameRecord
|
||||
{
|
||||
Phase = _phase,
|
||||
Frame = _frame++,
|
||||
FrameMs = frameMs,
|
||||
Gc0 = gc0 - _gc0,
|
||||
Gc1 = gc1 - _gc1,
|
||||
Gc2 = gc2 - _gc2,
|
||||
MonoDelta = mono - _mono,
|
||||
CameraPosition = Camera.main != null
|
||||
? Camera.main.transform.position
|
||||
: Vector3.zero,
|
||||
};
|
||||
foreach (var pair in Current)
|
||||
{
|
||||
record.Probes[pair.Key] = new HitchProbeSample
|
||||
{
|
||||
Calls = pair.Value.Calls,
|
||||
Ticks = pair.Value.Ticks,
|
||||
MaxTicks = pair.Value.MaxTicks,
|
||||
};
|
||||
}
|
||||
Frames.Add(record);
|
||||
}
|
||||
|
||||
Current.Clear();
|
||||
_gc0 = gc0;
|
||||
_gc1 = gc1;
|
||||
_gc2 = gc2;
|
||||
_mono = mono;
|
||||
_hasPendingFrame = true;
|
||||
}
|
||||
|
||||
static void SnapshotGc()
|
||||
{
|
||||
_gc0 = GC.CollectionCount(0);
|
||||
_gc1 = GC.CollectionCount(1);
|
||||
_gc2 = GC.CollectionCount(2);
|
||||
_mono = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
|
||||
}
|
||||
|
||||
internal static double TicksToMs(long ticks) =>
|
||||
ticks * 1000.0 / Stopwatch.Frequency;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using HarmonyLib;
|
||||
using S3.Core;
|
||||
using UnityEngine;
|
||||
|
||||
|
|
@ -10,6 +11,7 @@ public sealed class ProfilerModule : IModule
|
|||
public static ProfilerSettings Settings { get; private set; } = new();
|
||||
|
||||
private static GameObject? _hostGo;
|
||||
private static Harmony? _harmony;
|
||||
|
||||
public ProfilerModule() => Settings = SettingsStore.Load<ProfilerSettings>(SettingsFile);
|
||||
|
||||
|
|
@ -28,15 +30,24 @@ public sealed class ProfilerModule : IModule
|
|||
|
||||
public void OnEnable()
|
||||
{
|
||||
_harmony = new Harmony("S3.profiler");
|
||||
_harmony.CreateClassProcessor(typeof(BenchmarkCommandPatch)).Patch();
|
||||
HitchProbePatches.Install(_harmony);
|
||||
_hostGo = new GameObject("[S3] ProfilerHost");
|
||||
UnityEngine.Object.DontDestroyOnLoad(_hostGo);
|
||||
var overlay = _hostGo.AddComponent<ProfilerOverlayGUI>();
|
||||
overlay.Visible = Settings.visible;
|
||||
overlay.Opacity = Settings.opacity;
|
||||
_hostGo.AddComponent<AutomatedBenchmark>();
|
||||
_hostGo.AddComponent<HitchFrameDriver>();
|
||||
_hostGo.AddComponent<HitchLateFrameDriver>();
|
||||
}
|
||||
|
||||
public void OnDisable()
|
||||
{
|
||||
HitchSampler.CancelCapture();
|
||||
_harmony?.UnpatchAll(_harmony.Id);
|
||||
_harmony = null;
|
||||
if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo);
|
||||
_hostGo = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,4 +10,7 @@ public class ProfilerSettings
|
|||
public float opacity = 0.85f;
|
||||
public bool showPhysicsSection = true;
|
||||
public bool showMeshLodSection = true;
|
||||
public bool captureHitchProbes = true;
|
||||
public float hitchThresholdMs = 100f;
|
||||
public bool captureUnityBinaryLog = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,39 @@ static class ProfilerSettingsUI
|
|||
if (newMesh != s.showMeshLodSection && meshAvail)
|
||||
{ s.showMeshLodSection = newMesh; changed = true; }
|
||||
|
||||
GUILayout.Space(10f);
|
||||
GUILayout.Label("<b>Benchmark hitch capture</b>");
|
||||
GUILayout.Space(4f);
|
||||
|
||||
bool newCapture = GUILayout.Toggle(
|
||||
s.captureHitchProbes,
|
||||
" Attribute base-game work on every benchmark frame");
|
||||
if (newCapture != s.captureHitchProbes)
|
||||
{
|
||||
s.captureHitchProbes = newCapture;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Label($"Hitch threshold: {s.hitchThresholdMs:F0}ms", GUILayout.Width(160f));
|
||||
float newThreshold = GUILayout.HorizontalSlider(
|
||||
s.hitchThresholdMs, 33f, 250f, GUILayout.Width(200f));
|
||||
GUILayout.EndHorizontal();
|
||||
if (Mathf.Abs(newThreshold - s.hitchThresholdMs) > 0.5f)
|
||||
{
|
||||
s.hitchThresholdMs = newThreshold;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
bool newBinary = GUILayout.Toggle(
|
||||
s.captureUnityBinaryLog,
|
||||
" Write Unity binary profiler data (high overhead)");
|
||||
if (newBinary != s.captureUnityBinaryLog)
|
||||
{
|
||||
s.captureUnityBinaryLog = newBinary;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
GUILayout.EndVertical();
|
||||
|
||||
if (changed) ProfilerModule.Persist();
|
||||
|
|
|
|||
389
src/Modules/QuickActions/ConsistActions.cs
Normal file
389
src/Modules/QuickActions/ConsistActions.cs
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
using System.Collections.Generic;
|
||||
using Game.Messages;
|
||||
using Game.State;
|
||||
using HarmonyLib;
|
||||
using Model;
|
||||
using S3.Core;
|
||||
|
||||
namespace S3.Modules.QuickActions;
|
||||
|
||||
static class ConsistActions
|
||||
{
|
||||
public enum Kind
|
||||
{
|
||||
SetLead,
|
||||
ReleaseHandbrakes,
|
||||
ApplyHandbrakes,
|
||||
BleedAll,
|
||||
OpenCocks,
|
||||
CloseCocks,
|
||||
ConnectAir,
|
||||
IdleBail,
|
||||
SelectLead,
|
||||
SelectLoco,
|
||||
}
|
||||
|
||||
public static IReadOnlyList<(Kind kind, string label)> VisibleKinds(Car clicked)
|
||||
{
|
||||
var s = QuickActionsModule.Settings;
|
||||
var lead = new List<(Kind, string)>(3);
|
||||
var body = new List<(Kind, string)>(8);
|
||||
if (s.consistSetLead && ShouldShow(Kind.SetLead, clicked))
|
||||
lead.Add((Kind.SetLead, "Set\nLead"));
|
||||
if (s.consistSelectLead && ShouldShow(Kind.SelectLead, clicked))
|
||||
lead.Add((Kind.SelectLead, "Select\nLead"));
|
||||
if (s.consistSelectLoco && ShouldShow(Kind.SelectLoco, clicked))
|
||||
lead.Add((Kind.SelectLoco, "Select\nLoco"));
|
||||
if (s.consistReleaseHandbrakes && ShouldShow(Kind.ReleaseHandbrakes, clicked))
|
||||
body.Add((Kind.ReleaseHandbrakes, "Release All\nHandbrakes"));
|
||||
if (s.consistApplyHandbrakes && ShouldShow(Kind.ApplyHandbrakes, clicked))
|
||||
body.Add((Kind.ApplyHandbrakes, "Apply All\nHandbrakes"));
|
||||
if (s.consistBleedAll && ShouldShow(Kind.BleedAll, clicked))
|
||||
body.Add((Kind.BleedAll, "Bleed All"));
|
||||
if (s.consistOpenCocks && ShouldShow(Kind.OpenCocks, clicked))
|
||||
body.Add((Kind.OpenCocks, "Open All\nAnglecocks"));
|
||||
if (s.consistCloseCocks && ShouldShow(Kind.CloseCocks, clicked))
|
||||
body.Add((Kind.CloseCocks, "Close All\nAnglecocks"));
|
||||
if (s.consistConnectAir && ShouldShow(Kind.ConnectAir, clicked))
|
||||
body.Add((Kind.ConnectAir, "Attach All\nHoses"));
|
||||
if (s.consistIdleBail && ShouldShow(Kind.IdleBail, clicked))
|
||||
body.Add((Kind.IdleBail, "Idle and\nBail All"));
|
||||
body.InsertRange(body.Count / 2, lead);
|
||||
return body;
|
||||
}
|
||||
|
||||
public static bool AnyVisible(Car clicked) => VisibleKinds(clicked).Count > 0;
|
||||
|
||||
static bool ShouldShow(Kind kind, Car clicked)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
Kind.SetLead => clicked is BaseLocomotive && CountLocos(clicked) >= 2,
|
||||
Kind.SelectLead => TryLead(clicked, out BaseLocomotive lead) && lead != clicked,
|
||||
Kind.SelectLoco => TryOnlyLoco(clicked, out BaseLocomotive loco) && loco != clicked,
|
||||
Kind.IdleBail => CountLocos(clicked) >= 1,
|
||||
Kind.ConnectAir => CountAirJoints(clicked, QuickActionsModule.Settings.consistConnectAirExcludeLocos) > 0,
|
||||
Kind.ReleaseHandbrakes => CountAffected(kind, clicked) > 0,
|
||||
Kind.ApplyHandbrakes => CountAffected(kind, clicked) > 0,
|
||||
Kind.BleedAll => CountAffected(kind, clicked) > 0,
|
||||
Kind.OpenCocks => CountAffected(kind, clicked) > 0,
|
||||
Kind.CloseCocks => CountAffected(kind, clicked) > 0,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
public static bool CanRun(Kind kind, Car clicked)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
Kind.SetLead => clicked is BaseLocomotive && CountLocos(clicked) >= 2,
|
||||
Kind.IdleBail => CountLocos(clicked) >= 1,
|
||||
Kind.SelectLead => TryLead(clicked, out BaseLocomotive lead) && lead != clicked,
|
||||
Kind.SelectLoco => TryOnlyLoco(clicked, out BaseLocomotive loco) && loco != clicked,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
public static void Run(Kind kind, Car clicked)
|
||||
{
|
||||
if (!CanRun(kind, clicked)) return;
|
||||
switch (kind)
|
||||
{
|
||||
case Kind.SetLead:
|
||||
MuConsistAction.Run(clicked);
|
||||
break;
|
||||
case Kind.ReleaseHandbrakes:
|
||||
ForEachCar(clicked, QuickActionsModule.Settings.consistReleaseHandbrakesExcludeLocos,
|
||||
c => c.SetHandbrake(false));
|
||||
break;
|
||||
case Kind.ApplyHandbrakes:
|
||||
ForEachCar(clicked, QuickActionsModule.Settings.consistApplyHandbrakesExcludeLocos,
|
||||
c => c.SetHandbrake(true));
|
||||
break;
|
||||
case Kind.BleedAll:
|
||||
ForEachCar(clicked, QuickActionsModule.Settings.consistBleedAllExcludeLocos, c =>
|
||||
{
|
||||
if (c.SupportsBleed())
|
||||
c.SetBleed();
|
||||
});
|
||||
break;
|
||||
case Kind.OpenCocks:
|
||||
SetAllCocks(clicked, 1f, QuickActionsModule.Settings.consistOpenCocksExcludeLocos);
|
||||
break;
|
||||
case Kind.CloseCocks:
|
||||
SetAllCocks(clicked, 0f, QuickActionsModule.Settings.consistCloseCocksExcludeLocos);
|
||||
break;
|
||||
case Kind.ConnectAir:
|
||||
ConnectAllAir(clicked, QuickActionsModule.Settings.consistConnectAirExcludeLocos);
|
||||
break;
|
||||
case Kind.IdleBail:
|
||||
IdleBail(clicked);
|
||||
break;
|
||||
case Kind.SelectLead:
|
||||
if (TryLead(clicked, out BaseLocomotive lead))
|
||||
Select(lead);
|
||||
break;
|
||||
case Kind.SelectLoco:
|
||||
if (TryOnlyLoco(clicked, out BaseLocomotive loco))
|
||||
Select(loco);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public static string Preview(Kind kind, Car clicked)
|
||||
{
|
||||
int n = CountAffected(kind, clicked);
|
||||
return kind switch
|
||||
{
|
||||
Kind.SetLead => "Set this locomotive as lead",
|
||||
Kind.ReleaseHandbrakes => $"Release handbrake on {n}",
|
||||
Kind.ApplyHandbrakes => $"Apply handbrake on {n}",
|
||||
Kind.BleedAll => $"Bleed {n}",
|
||||
Kind.OpenCocks => "",
|
||||
Kind.CloseCocks => "",
|
||||
Kind.ConnectAir => "",
|
||||
Kind.IdleBail => $"Idle and bail {n}",
|
||||
Kind.SelectLead => TryLead(clicked, out BaseLocomotive lead)
|
||||
? $"Select {lead.DisplayName}"
|
||||
: "",
|
||||
Kind.SelectLoco => TryOnlyLoco(clicked, out BaseLocomotive loco)
|
||||
? $"Select {loco.DisplayName}"
|
||||
: "",
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
|
||||
public static int CountAffected(Kind kind, Car clicked)
|
||||
{
|
||||
var s = QuickActionsModule.Settings;
|
||||
return kind switch
|
||||
{
|
||||
Kind.ReleaseHandbrakes => CountCars(clicked, s.consistReleaseHandbrakesExcludeLocos),
|
||||
Kind.ApplyHandbrakes => CountCars(clicked, s.consistApplyHandbrakesExcludeLocos),
|
||||
Kind.BleedAll => CountCars(clicked, s.consistBleedAllExcludeLocos, c => c.SupportsBleed()),
|
||||
Kind.OpenCocks => CountCars(clicked, s.consistOpenCocksExcludeLocos),
|
||||
Kind.CloseCocks => CountCars(clicked, s.consistCloseCocksExcludeLocos),
|
||||
Kind.ConnectAir => CountAirJoints(clicked, s.consistConnectAirExcludeLocos),
|
||||
Kind.IdleBail => CountLocos(clicked),
|
||||
Kind.SetLead => 1,
|
||||
Kind.SelectLead => 1,
|
||||
Kind.SelectLoco => 1,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
static int CountCars(Car origin, bool excludeLocos, System.Func<Car, bool>? pred = null)
|
||||
{
|
||||
int n = 0;
|
||||
try
|
||||
{
|
||||
foreach (Car c in origin.EnumerateCoupled())
|
||||
{
|
||||
if (excludeLocos && c is BaseLocomotive) continue;
|
||||
if (pred != null && !pred(c)) continue;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
catch { /* counted what we could */ }
|
||||
return n;
|
||||
}
|
||||
|
||||
static int CountAirJoints(Car origin, bool excludeLocos)
|
||||
{
|
||||
int n = 0;
|
||||
Car? prev = null;
|
||||
try
|
||||
{
|
||||
foreach (Car c in origin.EnumerateCoupled())
|
||||
{
|
||||
if (prev != null && WouldConnect(prev, c, excludeLocos))
|
||||
n++;
|
||||
prev = c;
|
||||
}
|
||||
}
|
||||
catch { /* counted what we could */ }
|
||||
return n;
|
||||
}
|
||||
|
||||
static bool WouldConnect(Car a, Car b, bool excludeLocos)
|
||||
{
|
||||
if (excludeLocos && (a is BaseLocomotive || b is BaseLocomotive))
|
||||
return false;
|
||||
Car.LogicalEnd? joint = null;
|
||||
if (EndGearActions.TryNeighbor(a, Car.LogicalEnd.A, out Car nA, out _) && nA == b)
|
||||
joint = Car.LogicalEnd.A;
|
||||
else if (EndGearActions.TryNeighbor(a, Car.LogicalEnd.B, out Car nB, out _) && nB == b)
|
||||
joint = Car.LogicalEnd.B;
|
||||
if (joint == null) return false;
|
||||
return a[joint.Value].IsCoupled && !a[joint.Value].IsAirConnected;
|
||||
}
|
||||
|
||||
static int CountLocos(Car origin)
|
||||
{
|
||||
int n = 0;
|
||||
try
|
||||
{
|
||||
foreach (Car c in origin.EnumerateCoupled())
|
||||
{
|
||||
if (c is BaseLocomotive)
|
||||
n++;
|
||||
}
|
||||
}
|
||||
catch { /* counted what we could */ }
|
||||
return n;
|
||||
}
|
||||
|
||||
static List<BaseLocomotive> CollectLocos(Car origin)
|
||||
{
|
||||
var list = new List<BaseLocomotive>();
|
||||
try
|
||||
{
|
||||
foreach (Car c in origin.EnumerateCoupled())
|
||||
{
|
||||
if (c is BaseLocomotive loco)
|
||||
list.Add(loco);
|
||||
}
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] loco walk failed: {e.Message}");
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
static bool TryLead(Car origin, out BaseLocomotive lead)
|
||||
{
|
||||
lead = null!;
|
||||
var locos = CollectLocos(origin);
|
||||
if (locos.Count < 2) return false;
|
||||
foreach (BaseLocomotive loco in locos)
|
||||
{
|
||||
if (!IsMuOn(loco))
|
||||
{
|
||||
lead = loco;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
lead = locos[0];
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool TryOnlyLoco(Car origin, out BaseLocomotive loco)
|
||||
{
|
||||
loco = null!;
|
||||
var locos = CollectLocos(origin);
|
||||
if (locos.Count != 1) return false;
|
||||
loco = locos[0];
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool IsMuOn(BaseLocomotive loco)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Traverse.Create(loco).Property<bool>("IsMuEnabled").Value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static void Select(Car car)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (TrainController.Shared != null)
|
||||
TrainController.Shared.SelectedCar = car;
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] select {car.DisplayName} failed: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static void ForEachCar(Car origin, bool excludeLocos, System.Action<Car> act)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (Car c in origin.EnumerateCoupled())
|
||||
{
|
||||
if (excludeLocos && c is BaseLocomotive) continue;
|
||||
try { act(c); }
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] consist action failed on {c.DisplayName}: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] EnumerateCoupled failed: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static void SetAllCocks(Car origin, float value, bool excludeLocos)
|
||||
{
|
||||
ForEachCar(origin, excludeLocos, c =>
|
||||
{
|
||||
c.ApplyEndGearChange(Car.LogicalEnd.A, Car.EndGearStateKey.Anglecock, value);
|
||||
c.ApplyEndGearChange(Car.LogicalEnd.B, Car.EndGearStateKey.Anglecock, value);
|
||||
});
|
||||
}
|
||||
|
||||
static void ConnectAllAir(Car origin, bool excludeLocos)
|
||||
{
|
||||
Car? prev = null;
|
||||
try
|
||||
{
|
||||
foreach (Car c in origin.EnumerateCoupled())
|
||||
{
|
||||
if (prev != null)
|
||||
TryConnectPair(prev, c, excludeLocos);
|
||||
prev = c;
|
||||
}
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] connect-air walk failed: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static void TryConnectPair(Car a, Car b, bool excludeLocos)
|
||||
{
|
||||
if (excludeLocos && (a is BaseLocomotive || b is BaseLocomotive))
|
||||
return;
|
||||
|
||||
Car.LogicalEnd? joint = null;
|
||||
if (EndGearActions.TryNeighbor(a, Car.LogicalEnd.A, out Car nA, out _) && nA == b)
|
||||
joint = Car.LogicalEnd.A;
|
||||
else if (EndGearActions.TryNeighbor(a, Car.LogicalEnd.B, out Car nB, out _) && nB == b)
|
||||
joint = Car.LogicalEnd.B;
|
||||
if (joint == null) return;
|
||||
if (!a[joint.Value].IsCoupled || a[joint.Value].IsAirConnected) return;
|
||||
|
||||
try
|
||||
{
|
||||
var msg = new SetGladhandsConnected(a.id, b.id, true);
|
||||
if (!StateManager.CheckAuthorizedToSendMessage(msg)) return;
|
||||
StateManager.ApplyLocal(msg);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] connect air {a.DisplayName}/{b.DisplayName}: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static void IdleBail(Car origin)
|
||||
{
|
||||
ForEachCar(origin, excludeLocos: false, c =>
|
||||
{
|
||||
if (c is not BaseLocomotive loco) return;
|
||||
loco.SendPropertyChange(PropertyChange.Control.Throttle, 0f);
|
||||
if (loco.ControlHelper != null)
|
||||
loco.ControlHelper.BailOff();
|
||||
else
|
||||
loco.SendPropertyChange(PropertyChange.Control.LocomotiveBrake, -0.1f);
|
||||
});
|
||||
}
|
||||
}
|
||||
10
src/Modules/QuickActions/ConsistSlotHover.cs
Normal file
10
src/Modules/QuickActions/ConsistSlotHover.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
namespace S3.Modules.QuickActions;
|
||||
|
||||
sealed class ConsistSlotHover : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
|
||||
{
|
||||
public void OnPointerEnter(PointerEventData eventData) => EndGearOverlay.NotifyConsistHover(true);
|
||||
public void OnPointerExit(PointerEventData eventData) => EndGearOverlay.NotifyConsistHover(false);
|
||||
}
|
||||
240
src/Modules/QuickActions/ContextMenuPatch.cs
Normal file
240
src/Modules/QuickActions/ContextMenuPatch.cs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using Game.Messages;
|
||||
using Game.State;
|
||||
using HarmonyLib;
|
||||
using Model;
|
||||
using RollingStock;
|
||||
using UI;
|
||||
using UI.ContextMenu;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using GameContextMenu = UI.ContextMenu.ContextMenu;
|
||||
|
||||
namespace S3.Modules.QuickActions;
|
||||
|
||||
[HarmonyPatch(typeof(CarPickable), "HandleShowContextMenu")]
|
||||
static class CarPickableContextMenuPatch
|
||||
{
|
||||
static void Prefix(Car car) => ContextMenuActions.Stash(car);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(GameContextMenu), nameof(GameContextMenu.Show))]
|
||||
static class ContextMenuShowPatch
|
||||
{
|
||||
static void Prefix(GameContextMenu __instance) => ContextMenuActions.InjectIfNeeded(__instance);
|
||||
|
||||
static void Postfix(GameContextMenu __instance) =>
|
||||
EndGearOverlay.Attach(__instance, ContextMenuActions.MenuCar);
|
||||
}
|
||||
|
||||
// Vanilla sizes wedges by quadrant home-angle. Extra items bunch and desync
|
||||
// hitboxes. After that pass, space every item evenly, clockwise from 12 o'clock
|
||||
// — the same direction GetItemExtentAngles / WedgeImage already assume.
|
||||
[HarmonyPatch(typeof(GameContextMenu), "BuildItemAngles")]
|
||||
static class ContextMenuEvenLayoutPatch
|
||||
{
|
||||
static void Postfix(GameContextMenu __instance)
|
||||
{
|
||||
var t = Traverse.Create(__instance);
|
||||
var quadrants = t.Field<List<List<ContextMenuItem>>>("_quadrants").Value;
|
||||
var itemAngles = t.Field<Dictionary<(ContextMenuQuadrant quadrant, int index), float>>("_itemAngles").Value;
|
||||
if (quadrants == null || itemAngles == null) return;
|
||||
|
||||
var keys = new List<(ContextMenuQuadrant quadrant, int index)>();
|
||||
for (int q = 0; q < quadrants.Count; q++)
|
||||
{
|
||||
List<ContextMenuItem> list = quadrants[q];
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
keys.Add(((ContextMenuQuadrant)q, i));
|
||||
}
|
||||
if (keys.Count == 0) return;
|
||||
|
||||
float step = 360f / keys.Count;
|
||||
itemAngles.Clear();
|
||||
for (int i = 0; i < keys.Count; i++)
|
||||
{
|
||||
// Walk order is clockwise (decreasing angle). Keep (0, 360] so
|
||||
// WedgeImage.IsRaycastLocationValid does not see a negative start.
|
||||
float ang = 90f - i * step;
|
||||
if (ang <= 0f) ang += 360f;
|
||||
itemAngles[keys[i]] = ang;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LerpAngle can still hand SetAngle a negative start when a slice crosses 0°.
|
||||
[HarmonyPatch(typeof(ContextMenuItem), nameof(ContextMenuItem.SetAngle))]
|
||||
static class ContextMenuItemSetAnglePatch
|
||||
{
|
||||
static void Postfix(ContextMenuItem __instance)
|
||||
{
|
||||
if (__instance.wedgeImage == null) return;
|
||||
__instance.wedgeImage.startAngle = Mathf.Repeat(__instance.wedgeImage.startAngle, 360f);
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch]
|
||||
static class ContextMenuHidePatch
|
||||
{
|
||||
static IEnumerable<System.Reflection.MethodBase> TargetMethods()
|
||||
{
|
||||
foreach (var m in typeof(GameContextMenu).GetMethods(
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
|
||||
{
|
||||
if (m.Name == "Hide")
|
||||
yield return m;
|
||||
}
|
||||
}
|
||||
|
||||
static void Prefix()
|
||||
{
|
||||
EndGearOverlay.Detach();
|
||||
ContextMenuActions.ClearMenuCar();
|
||||
}
|
||||
}
|
||||
|
||||
static class ContextMenuActions
|
||||
{
|
||||
static Car? _pending;
|
||||
static Car? _menuCar;
|
||||
static Sprite? _consistSprite;
|
||||
static bool _consistSpriteTried;
|
||||
|
||||
public static Car? MenuCar => _menuCar;
|
||||
public static ContextMenuItem? ConsistItem { get; private set; }
|
||||
|
||||
public static void Stash(Car car)
|
||||
{
|
||||
_pending = car;
|
||||
_menuCar = car;
|
||||
ConsistItem = null;
|
||||
}
|
||||
|
||||
public static void ClearMenuCar()
|
||||
{
|
||||
_menuCar = null;
|
||||
ConsistItem = null;
|
||||
}
|
||||
|
||||
public static void InjectIfNeeded(GameContextMenu menu)
|
||||
{
|
||||
Car? car = _pending;
|
||||
_pending = null;
|
||||
if (car == null) return;
|
||||
|
||||
if (!ShouldOfferConsist(car)) return;
|
||||
|
||||
Sprite? custom = ConsistSprite();
|
||||
Sprite sprite = custom ?? SpriteName.Select.Sprite();
|
||||
menu.AddButton(
|
||||
ContextMenuQuadrant.Unused1,
|
||||
"Consist",
|
||||
sprite,
|
||||
() => { });
|
||||
|
||||
try
|
||||
{
|
||||
var quadrants = Traverse.Create(menu).Field<List<List<ContextMenuItem>>>("_quadrants").Value;
|
||||
List<ContextMenuItem>? ours = quadrants?[(int)ContextMenuQuadrant.Unused1];
|
||||
ContextMenuItem? item = ours is { Count: > 0 } ? ours[ours.Count - 1] : null;
|
||||
if (item == null) return;
|
||||
|
||||
// Keep the pie open; consist work lives on the satellite wheel.
|
||||
item.OnClick = () => { };
|
||||
ConsistItem = item;
|
||||
var hover = item.gameObject.GetComponent<ConsistSlotHover>()
|
||||
?? item.gameObject.AddComponent<ConsistSlotHover>();
|
||||
hover.enabled = true;
|
||||
|
||||
if (item.image == null) return;
|
||||
|
||||
// Glyph is already #cdb993; white tint lets that color through.
|
||||
item.image.color = Color.white;
|
||||
if (custom == null) return;
|
||||
|
||||
item.image.preserveAspect = true;
|
||||
item.image.raycastTarget = false;
|
||||
var ignore = item.image.gameObject.GetComponent<LayoutElement>()
|
||||
?? item.image.gameObject.AddComponent<LayoutElement>();
|
||||
ignore.ignoreLayout = true;
|
||||
|
||||
var facing = item.image.gameObject.GetComponent<SetLeadIconFacing>()
|
||||
?? item.image.gameObject.AddComponent<SetLeadIconFacing>();
|
||||
facing.Car = car;
|
||||
facing.Image = item.image;
|
||||
facing.FlipFacing = false;
|
||||
facing.TargetSize = new Vector2(40f, 40f);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Core.Log.Warn($"[quickactions] consist icon polish failed: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static Sprite? ConsistSprite()
|
||||
{
|
||||
if (_consistSpriteTried) return _consistSprite;
|
||||
_consistSpriteTried = true;
|
||||
try
|
||||
{
|
||||
byte[]? png = ReadEmbeddedPng() ?? ReadLoosePng();
|
||||
if (png == null || png.Length == 0)
|
||||
{
|
||||
Core.Log.Warn("[quickactions] consist.png not found; using Select.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var tex = new Texture2D(2, 2, TextureFormat.RGBA32, mipChain: true);
|
||||
if (!ImageConversion.LoadImage(tex, png, markNonReadable: true))
|
||||
{
|
||||
Core.Log.Warn("[quickactions] consist.png failed to decode.");
|
||||
return null;
|
||||
}
|
||||
tex.filterMode = FilterMode.Trilinear;
|
||||
tex.wrapMode = TextureWrapMode.Clamp;
|
||||
tex.anisoLevel = 2;
|
||||
tex.mipMapBias = 0.15f;
|
||||
_consistSprite = Sprite.Create(
|
||||
tex,
|
||||
new Rect(0f, 0f, tex.width, tex.height),
|
||||
new Vector2(0.5f, 0.5f),
|
||||
100f);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Core.Log.Warn($"[quickactions] consist sprite load failed: {e.Message}");
|
||||
}
|
||||
return _consistSprite;
|
||||
}
|
||||
|
||||
static byte[]? ReadEmbeddedPng()
|
||||
{
|
||||
using Stream? stream = Assembly.GetExecutingAssembly()
|
||||
.GetManifestResourceStream("S3.QuickActions.consist.png");
|
||||
if (stream == null) return null;
|
||||
using var ms = new MemoryStream();
|
||||
stream.CopyTo(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
static byte[]? ReadLoosePng()
|
||||
{
|
||||
try
|
||||
{
|
||||
string path = Path.Combine(Main.ModEntry.Path, "consist.png");
|
||||
return File.Exists(path) ? File.ReadAllBytes(path) : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static bool ShouldOfferConsist(Car car)
|
||||
{
|
||||
return ConsistActions.AnyVisible(car);
|
||||
}
|
||||
}
|
||||
275
src/Modules/QuickActions/EndGearActions.cs
Normal file
275
src/Modules/QuickActions/EndGearActions.cs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
using System.Collections.Generic;
|
||||
using Game.Messages;
|
||||
using Game.State;
|
||||
using Model;
|
||||
using S3.Core;
|
||||
|
||||
namespace S3.Modules.QuickActions;
|
||||
|
||||
static class EndGearActions
|
||||
{
|
||||
public static bool TryNeighbor(Car car, Car.LogicalEnd end, out Car other, out Car.LogicalEnd otherEnd)
|
||||
{
|
||||
otherEnd = end == Car.LogicalEnd.A ? Car.LogicalEnd.B : Car.LogicalEnd.A;
|
||||
other = null!;
|
||||
try
|
||||
{
|
||||
return car.TryGetAdjacentCar(end, out other) && other != null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
other = null!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool CanDisconnectAll(Car car, Car.LogicalEnd end)
|
||||
{
|
||||
return car[end].IsCoupled;
|
||||
}
|
||||
|
||||
public static bool CanToggleCouple(Car car, Car.LogicalEnd end)
|
||||
{
|
||||
if (car[end].IsCoupled) return true;
|
||||
return TryNeighbor(car, end, out _, out _);
|
||||
}
|
||||
|
||||
public static bool CanToggleAir(Car car, Car.LogicalEnd end)
|
||||
{
|
||||
if (car[end].IsAirConnected) return true;
|
||||
return car[end].IsCoupled && TryNeighbor(car, end, out _, out _);
|
||||
}
|
||||
|
||||
public static void ToggleCouple(Car car, Car.LogicalEnd end)
|
||||
{
|
||||
bool couple = !car[end].IsCoupled;
|
||||
bool hasNeighbor = TryNeighbor(car, end, out Car other, out Car.LogicalEnd otherEnd);
|
||||
if (couple && !hasNeighbor) return;
|
||||
|
||||
try
|
||||
{
|
||||
car.ApplyEndGearChange(end, Car.EndGearStateKey.IsCoupled, couple);
|
||||
if (hasNeighbor)
|
||||
other.ApplyEndGearChange(otherEnd, Car.EndGearStateKey.IsCoupled, couple);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] couple toggle failed: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void ToggleAir(Car car, Car.LogicalEnd end)
|
||||
{
|
||||
bool connect = !car[end].IsAirConnected;
|
||||
if (connect && !car[end].IsCoupled) return;
|
||||
|
||||
Car? other = null;
|
||||
try { other = car.CoupledTo(end) ?? car.AirConnectedTo(end); }
|
||||
catch { /* fall through */ }
|
||||
if (other == null && !TryNeighbor(car, end, out other, out _))
|
||||
return;
|
||||
if (other == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var msg = new SetGladhandsConnected(car.id, other.id, connect);
|
||||
if (!StateManager.CheckAuthorizedToSendMessage(msg)) return;
|
||||
StateManager.ApplyLocal(msg);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] air toggle failed: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void ToggleCock(Car car, Car.LogicalEnd end)
|
||||
{
|
||||
float next = car[end].IsAnglecockOpen ? 0f : 1f;
|
||||
try
|
||||
{
|
||||
car.ApplyEndGearChange(end, Car.EndGearStateKey.Anglecock, next);
|
||||
if (car[end].IsCoupled && TryNeighbor(car, end, out Car other, out Car.LogicalEnd otherEnd))
|
||||
other.ApplyEndGearChange(otherEnd, Car.EndGearStateKey.Anglecock, next);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] anglecock toggle failed: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static string Preview(Car car, Car.LogicalEnd end, HoverSlot slot)
|
||||
{
|
||||
return slot switch
|
||||
{
|
||||
HoverSlot.Cut => CutPreview(CountCut(car, end)),
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
|
||||
static string CutPreview(int n)
|
||||
{
|
||||
if (n <= 0) return "";
|
||||
return $"Cut out {n} Cars";
|
||||
}
|
||||
|
||||
public static int CountCut(Car car, Car.LogicalEnd end)
|
||||
{
|
||||
if (!TryNeighbor(car, end, out Car other, out _))
|
||||
return 0;
|
||||
return CollectAway(other, car).Count;
|
||||
}
|
||||
|
||||
public static void DisconnectAll(Car car, Car.LogicalEnd end)
|
||||
{
|
||||
if (!TryNeighbor(car, end, out Car other, out Car.LogicalEnd otherEnd))
|
||||
return;
|
||||
|
||||
List<Car> detached = CollectAway(other, car);
|
||||
var s = QuickActionsModule.Settings;
|
||||
|
||||
try
|
||||
{
|
||||
car.ApplyEndGearChange(end, Car.EndGearStateKey.Anglecock, 0f);
|
||||
other.ApplyEndGearChange(otherEnd, Car.EndGearStateKey.Anglecock, 0f);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] drop-all anglecock failed: {e.Message}");
|
||||
}
|
||||
|
||||
if (car[end].IsAirConnected)
|
||||
{
|
||||
try
|
||||
{
|
||||
var msg = new SetGladhandsConnected(car.id, other.id, false);
|
||||
if (StateManager.CheckAuthorizedToSendMessage(msg))
|
||||
StateManager.ApplyLocal(msg);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] drop-all air failed: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
car.ApplyEndGearChange(end, Car.EndGearStateKey.IsCoupled, false);
|
||||
other.ApplyEndGearChange(otherEnd, Car.EndGearStateKey.IsCoupled, false);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] drop-all couple failed: {e.Message}");
|
||||
}
|
||||
|
||||
if (!s.dropHandbrakeOnCut) return;
|
||||
foreach (Car c in detached)
|
||||
{
|
||||
if (s.dropHandbrakeExcludeLocos && c is BaseLocomotive) continue;
|
||||
try { c.SetHandbrake(true); }
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] drop-all handbrake {c.DisplayName}: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsMadeUp(Car car, Car.LogicalEnd end)
|
||||
{
|
||||
try
|
||||
{
|
||||
var g = car[end];
|
||||
return g.IsCoupled && g.IsAirConnected && g.IsAnglecockOpen;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public static bool CanToggleJoint(Car car, Car.LogicalEnd end)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (car[end].IsCoupled) return true;
|
||||
return TryNeighbor(car, end, out _, out _);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public static void ToggleJoint(Car car, Car.LogicalEnd end)
|
||||
{
|
||||
if (IsMadeUp(car, end)) DisconnectAll(car, end);
|
||||
else MakeUp(car, end);
|
||||
}
|
||||
|
||||
public static void MakeUp(Car car, Car.LogicalEnd end)
|
||||
{
|
||||
bool hasNeighbor = TryNeighbor(car, end, out Car other, out Car.LogicalEnd otherEnd);
|
||||
if (!hasNeighbor)
|
||||
{
|
||||
try { if (!car[end].IsCoupled) return; }
|
||||
catch { return; }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!car[end].IsCoupled)
|
||||
{
|
||||
car.ApplyEndGearChange(end, Car.EndGearStateKey.IsCoupled, true);
|
||||
if (hasNeighbor)
|
||||
other.ApplyEndGearChange(otherEnd, Car.EndGearStateKey.IsCoupled, true);
|
||||
}
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] make-up couple failed: {e.Message}");
|
||||
}
|
||||
|
||||
Car? airOther = null;
|
||||
try { airOther = car.CoupledTo(end) ?? other; }
|
||||
catch { airOther = other; }
|
||||
if (airOther != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!car[end].IsAirConnected)
|
||||
{
|
||||
var msg = new SetGladhandsConnected(car.id, airOther.id, true);
|
||||
if (StateManager.CheckAuthorizedToSendMessage(msg))
|
||||
StateManager.ApplyLocal(msg);
|
||||
}
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] make-up air failed: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
car.ApplyEndGearChange(end, Car.EndGearStateKey.Anglecock, 1f);
|
||||
if (hasNeighbor)
|
||||
other.ApplyEndGearChange(otherEnd, Car.EndGearStateKey.Anglecock, 1f);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] make-up Anglecock failed: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static List<Car> CollectAway(Car start, Car blocked)
|
||||
{
|
||||
var list = new List<Car>();
|
||||
var seen = new HashSet<Car> { blocked };
|
||||
var stack = new Stack<Car>();
|
||||
stack.Push(start);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
Car c = stack.Pop();
|
||||
if (!seen.Add(c)) continue;
|
||||
list.Add(c);
|
||||
if (TryNeighbor(c, Car.LogicalEnd.A, out Car a, out _) && !seen.Contains(a))
|
||||
stack.Push(a);
|
||||
if (TryNeighbor(c, Car.LogicalEnd.B, out Car b, out _) && !seen.Contains(b))
|
||||
stack.Push(b);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
1027
src/Modules/QuickActions/EndGearOverlay.cs
Normal file
1027
src/Modules/QuickActions/EndGearOverlay.cs
Normal file
File diff suppressed because it is too large
Load diff
BIN
src/Modules/QuickActions/Icons/consist.png
Normal file
BIN
src/Modules/QuickActions/Icons/consist.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9 KiB |
126
src/Modules/QuickActions/Icons/make_consist_icon.py
Normal file
126
src/Modules/QuickActions/Icons/make_consist_icon.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Square consist glyph: curved track with boxcars. Factory icon cream #cdb993."""
|
||||
import math
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
OUT = r"D:\Seton\Documents\Projects\Railroader\SetonsSpecialSauce\src\Modules\QuickActions\Icons\consist.png"
|
||||
CREAM = (0xCD, 0xB9, 0x93, 255)
|
||||
SRC = 256
|
||||
|
||||
|
||||
def lerp(a, b, t):
|
||||
return a + (b - a) * t
|
||||
|
||||
|
||||
def curve(t):
|
||||
# S-curve from lower-left to upper-right so the glyph fills a square.
|
||||
p0 = (36.0, 220.0)
|
||||
p1 = (52.0, 20.0)
|
||||
p2 = (204.0, 236.0)
|
||||
p3 = (220.0, 36.0)
|
||||
u = 1.0 - t
|
||||
x = u**3 * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t**3 * p3[0]
|
||||
y = u**3 * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t**3 * p3[1]
|
||||
return x, y
|
||||
|
||||
|
||||
def deriv(t):
|
||||
dt = 0.002
|
||||
x0, y0 = curve(max(0.0, t - dt))
|
||||
x1, y1 = curve(min(1.0, t + dt))
|
||||
dx, dy = x1 - x0, y1 - y0
|
||||
l = math.hypot(dx, dy) or 1.0
|
||||
return dx / l, dy / l
|
||||
|
||||
|
||||
def normal(t):
|
||||
tx, ty = deriv(t)
|
||||
return -ty, tx
|
||||
|
||||
|
||||
def polyline(draw, offset, width, t0=0.0, t1=1.0, n=140):
|
||||
pts = []
|
||||
for i in range(n + 1):
|
||||
t = lerp(t0, t1, i / n)
|
||||
x, y = curve(t)
|
||||
nx, ny = normal(t)
|
||||
pts.append((x + nx * offset, y + ny * offset))
|
||||
draw.line(pts, fill=CREAM, width=width, joint="curve")
|
||||
r = width * 0.5
|
||||
draw.ellipse((pts[0][0] - r, pts[0][1] - r, pts[0][0] + r, pts[0][1] + r), fill=CREAM)
|
||||
draw.ellipse((pts[-1][0] - r, pts[-1][1] - r, pts[-1][0] + r, pts[-1][1] + r), fill=CREAM)
|
||||
|
||||
|
||||
def rotated_rect(cx, cy, w, h, ang):
|
||||
ca, sa = math.cos(ang), math.sin(ang)
|
||||
hw, hh = w * 0.5, h * 0.5
|
||||
local = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)]
|
||||
return [(cx + x * ca - y * sa, cy + x * sa + y * ca) for x, y in local]
|
||||
|
||||
|
||||
def outline_poly(draw, pts, width):
|
||||
closed = pts + [pts[0]]
|
||||
draw.line(closed, fill=CREAM, width=width, joint="curve")
|
||||
r = width * 0.48
|
||||
for x, y in pts:
|
||||
draw.ellipse((x - r, y - r, x + r, y + r), fill=CREAM)
|
||||
|
||||
|
||||
im = Image.new("RGBA", (SRC, SRC), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(im)
|
||||
|
||||
gauge = 11.0
|
||||
rail_w = 10
|
||||
box_stroke = 9
|
||||
|
||||
# Track a bit longer than the cut
|
||||
polyline(d, -gauge, rail_w, t0=0.0, t1=1.0)
|
||||
polyline(d, gauge, rail_w, t0=0.0, t1=1.0)
|
||||
|
||||
# A few ties so it reads as track at pie size; skip the dense ladder.
|
||||
for i in range(7):
|
||||
t = lerp(0.08, 0.92, i / 6)
|
||||
x, y = curve(t)
|
||||
nx, ny = normal(t)
|
||||
span = gauge + 5
|
||||
d.line(
|
||||
(x - nx * span, y - ny * span, x + nx * span, y + ny * span),
|
||||
fill=CREAM,
|
||||
width=7,
|
||||
)
|
||||
|
||||
car_len, car_wid = 44.0, 32.0
|
||||
ts = (0.18, 0.39, 0.61, 0.82)
|
||||
for t in ts:
|
||||
x, y = curve(t)
|
||||
tx, ty = deriv(t)
|
||||
ang = math.atan2(ty, tx)
|
||||
pts = rotated_rect(x, y, car_len, car_wid, ang)
|
||||
outline_poly(d, pts, box_stroke)
|
||||
|
||||
# Crop to ink and fit into 128 with even padding
|
||||
px = im.load()
|
||||
minx, miny, maxx, maxy = SRC, SRC, 0, 0
|
||||
for y in range(SRC):
|
||||
for x in range(SRC):
|
||||
if px[x, y][3] > 16:
|
||||
if x < minx: minx = x
|
||||
if y < miny: miny = y
|
||||
if x > maxx: maxx = x
|
||||
if y > maxy: maxy = y
|
||||
|
||||
pad = 8
|
||||
minx = max(0, minx - pad)
|
||||
miny = max(0, miny - pad)
|
||||
maxx = min(SRC - 1, maxx + pad)
|
||||
maxy = min(SRC - 1, maxy + pad)
|
||||
crop = im.crop((minx, miny, maxx + 1, maxy + 1))
|
||||
cw, ch = crop.size
|
||||
side = 128
|
||||
scale = min((side - 8) / cw, (side - 8) / ch)
|
||||
nw, nh = max(1, int(round(cw * scale))), max(1, int(round(ch * scale)))
|
||||
fitted = crop.resize((nw, nh), Image.Resampling.LANCZOS)
|
||||
out = Image.new("RGBA", (side, side), (0, 0, 0, 0))
|
||||
out.paste(fitted, ((side - nw) // 2, (side - nh) // 2), fitted)
|
||||
out = out.filter(ImageFilter.UnsharpMask(radius=1.0, percent=100, threshold=2))
|
||||
out.save(OUT, "PNG")
|
||||
print("crop", cw, "x", ch, "fitted", nw, "x", nh)
|
||||
BIN
src/Modules/QuickActions/Icons/set-lead.png
Normal file
BIN
src/Modules/QuickActions/Icons/set-lead.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
162
src/Modules/QuickActions/MuConsistAction.cs
Normal file
162
src/Modules/QuickActions/MuConsistAction.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using Game.Messages;
|
||||
using Game.State;
|
||||
using Model;
|
||||
using S3.Core;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.QuickActions;
|
||||
|
||||
static class MuConsistAction
|
||||
{
|
||||
public static void Run(Car clicked)
|
||||
{
|
||||
if (clicked is not BaseLocomotive lead)
|
||||
return;
|
||||
|
||||
List<BaseLocomotive> locos;
|
||||
try
|
||||
{
|
||||
locos = CollectLocos(lead);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Error($"[quickactions] Set Lead walk failed: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (locos.Count < 2)
|
||||
{
|
||||
Log.Info("[quickactions] Set Lead: no other locomotives in this cut.");
|
||||
return;
|
||||
}
|
||||
|
||||
var host = QuickActionsModule.Host;
|
||||
if (host == null)
|
||||
{
|
||||
ApplyImmediate(lead, locos);
|
||||
return;
|
||||
}
|
||||
|
||||
host.StopAllCoroutines();
|
||||
host.StartCoroutine(ApplyRoutine(lead, locos));
|
||||
}
|
||||
|
||||
static List<BaseLocomotive> CollectLocos(BaseLocomotive origin)
|
||||
{
|
||||
var locos = new List<BaseLocomotive>();
|
||||
foreach (Car c in origin.EnumerateCoupled())
|
||||
{
|
||||
if (c is BaseLocomotive loco)
|
||||
locos.Add(loco);
|
||||
}
|
||||
return locos;
|
||||
}
|
||||
|
||||
static IEnumerator ApplyRoutine(BaseLocomotive lead, List<BaseLocomotive> locos)
|
||||
{
|
||||
// AE on a trailer will turn MU back off. Drop those AEs first and let
|
||||
// OffDuty land before we touch MU / Cut Out.
|
||||
DisableTrailerAutoEngineers(lead, locos);
|
||||
yield return null;
|
||||
|
||||
// Always cycle trailers off first so DPU-mod / large-consist glitches unstick,
|
||||
// even when MU/Cut Out already read as on.
|
||||
foreach (BaseLocomotive loco in locos)
|
||||
{
|
||||
if (loco == lead) continue;
|
||||
SetBool(loco, PropertyChange.Control.Mu, false);
|
||||
SetBool(loco, PropertyChange.Control.CutOut, false);
|
||||
}
|
||||
|
||||
SetLead(lead);
|
||||
IdleAll(locos);
|
||||
|
||||
yield return null;
|
||||
|
||||
foreach (BaseLocomotive loco in locos)
|
||||
{
|
||||
if (loco == lead) continue;
|
||||
SetBool(loco, PropertyChange.Control.CutOut, true);
|
||||
SetBool(loco, PropertyChange.Control.Mu, true);
|
||||
}
|
||||
|
||||
Log.Info($"[quickactions] Set Lead: {locos.Count - 1} trailer(s) MU'd to {lead.DisplayName}.");
|
||||
}
|
||||
|
||||
static void ApplyImmediate(BaseLocomotive lead, List<BaseLocomotive> locos)
|
||||
{
|
||||
DisableTrailerAutoEngineers(lead, locos);
|
||||
foreach (BaseLocomotive loco in locos)
|
||||
{
|
||||
if (loco == lead) continue;
|
||||
SetBool(loco, PropertyChange.Control.Mu, false);
|
||||
SetBool(loco, PropertyChange.Control.CutOut, false);
|
||||
SetBool(loco, PropertyChange.Control.CutOut, true);
|
||||
SetBool(loco, PropertyChange.Control.Mu, true);
|
||||
}
|
||||
SetLead(lead);
|
||||
IdleAll(locos);
|
||||
Log.Info($"[quickactions] Set Lead (no host): {locos.Count - 1} trailer(s) MU'd to {lead.DisplayName}.");
|
||||
}
|
||||
|
||||
static void DisableTrailerAutoEngineers(BaseLocomotive lead, List<BaseLocomotive> locos)
|
||||
{
|
||||
foreach (BaseLocomotive loco in locos)
|
||||
{
|
||||
if (loco == lead) continue;
|
||||
DisableAutoEngineer(loco);
|
||||
}
|
||||
}
|
||||
|
||||
static void DisableAutoEngineer(BaseLocomotive loco)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cmd = new AutoEngineerCommand(loco.id, AutoEngineerMode.Off, true, 0, null, null, null);
|
||||
StateManager.ApplyLocal(cmd);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] disable AE failed on {loco.DisplayName}: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static void SetLead(BaseLocomotive lead)
|
||||
{
|
||||
SetBool(lead, PropertyChange.Control.Mu, false);
|
||||
SetBool(lead, PropertyChange.Control.CutOut, false);
|
||||
}
|
||||
|
||||
static void IdleAll(List<BaseLocomotive> locos)
|
||||
{
|
||||
foreach (BaseLocomotive loco in locos)
|
||||
{
|
||||
try
|
||||
{
|
||||
loco.SendPropertyChange(PropertyChange.Control.Throttle, 0f);
|
||||
if (loco.ControlHelper != null)
|
||||
loco.ControlHelper.BailOff();
|
||||
else
|
||||
loco.SendPropertyChange(PropertyChange.Control.LocomotiveBrake, -0.1f);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] idle/bail failed on {loco.DisplayName}: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void SetBool(BaseLocomotive loco, PropertyChange.Control control, bool value)
|
||||
{
|
||||
try
|
||||
{
|
||||
loco.SendPropertyChange(control, value);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Log.Warn($"[quickactions] {control}={value} failed on {loco.DisplayName}: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
400
src/Modules/QuickActions/PieCenterHud.cs
Normal file
400
src/Modules/QuickActions/PieCenterHud.cs
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using TMPro;
|
||||
using UI.ContextMenu;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using GameContextMenu = UI.ContextMenu.ContextMenu;
|
||||
|
||||
namespace S3.Modules.QuickActions;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the factory reporting-mark hole with a panel matching the secondary
|
||||
/// wedges, plus a rim gauge: weight fills the arc, colored tickers mark max TE,
|
||||
/// current TE, and grade need.
|
||||
/// </summary>
|
||||
sealed class PieCenterHud
|
||||
{
|
||||
static readonly Color Panel = new Color(0.07f, 0.07f, 0.065f, 0.97f);
|
||||
static readonly Color Fg = new Color(0xca / 255f, 0xc4 / 255f, 0xb8 / 255f, 1f);
|
||||
static readonly Color FgDim = new Color(0xca / 255f, 0xc4 / 255f, 0xb8 / 255f, 0.88f);
|
||||
static readonly Color Border = new Color(0xcd / 255f, 0xb9 / 255f, 0x93 / 255f, 1f);
|
||||
static readonly Color Track = new Color(0.16f, 0.14f, 0.11f, 0.96f);
|
||||
static readonly Color WeightFill = new Color(1f, 0.92f, 0.70f, 0.88f);
|
||||
static readonly Color MaxTe = new Color(1f, 0.85f, 0.22f, 1f);
|
||||
static readonly Color CurrentTe = new Color(0.15f, 0.92f, 1f, 1f);
|
||||
static readonly Color HereTe = new Color(1f, 0.55f, 0.12f, 1f);
|
||||
static readonly Color NeedTe = new Color(1f, 0.32f, 0.18f, 1f);
|
||||
|
||||
const float BorderPx = 2.4f;
|
||||
const float GaugeThick = 7f;
|
||||
const float TickInward = 12f;
|
||||
const float TickWidth = 3.4f;
|
||||
// Min at 8 o'clock (left). Values climb clockwise around the rim.
|
||||
// WedgeImage itself only sweeps CCW, so the track mesh starts at the max end.
|
||||
const float GaugeMin = 250f;
|
||||
const float GaugeRange = 320f;
|
||||
|
||||
static Sprite? _white;
|
||||
static Sprite? _circle;
|
||||
|
||||
readonly RectTransform _gaugeRoot;
|
||||
readonly WedgeImage _weight;
|
||||
readonly RectTransform _tickMax;
|
||||
readonly RectTransform _tickCurrent;
|
||||
readonly RectTransform _tickHere;
|
||||
readonly RectTransform _tickNeed;
|
||||
readonly Image _tickMaxImg;
|
||||
readonly Image _tickCurrentImg;
|
||||
readonly Image _tickHereImg;
|
||||
readonly Image _tickNeedImg;
|
||||
readonly TMP_Text _road;
|
||||
readonly TMP_Text _stats;
|
||||
readonly TMP_Text _preview;
|
||||
readonly float _holeR;
|
||||
readonly float _discR;
|
||||
readonly float _fontRoad;
|
||||
readonly float _fontBody;
|
||||
readonly List<Graphic> _hiddenFactory = new();
|
||||
|
||||
PieCenterHud(
|
||||
RectTransform gaugeRoot, WedgeImage weight,
|
||||
RectTransform tickMax, RectTransform tickCurrent, RectTransform tickHere, RectTransform tickNeed,
|
||||
Image tickMaxImg, Image tickCurrentImg, Image tickHereImg, Image tickNeedImg,
|
||||
TMP_Text road, TMP_Text stats, TMP_Text preview,
|
||||
float holeR, float discR, float fontRoad, float fontBody,
|
||||
List<Graphic> hiddenFactory)
|
||||
{
|
||||
_gaugeRoot = gaugeRoot;
|
||||
_weight = weight;
|
||||
_tickMax = tickMax;
|
||||
_tickCurrent = tickCurrent;
|
||||
_tickHere = tickHere;
|
||||
_tickNeed = tickNeed;
|
||||
_tickMaxImg = tickMaxImg;
|
||||
_tickCurrentImg = tickCurrentImg;
|
||||
_tickHereImg = tickHereImg;
|
||||
_tickNeedImg = tickNeedImg;
|
||||
_road = road;
|
||||
_stats = stats;
|
||||
_preview = preview;
|
||||
_holeR = holeR;
|
||||
_discR = discR;
|
||||
_fontRoad = fontRoad;
|
||||
_fontBody = fontBody;
|
||||
_hiddenFactory = hiddenFactory;
|
||||
}
|
||||
|
||||
public static PieCenterHud? TryCreate(GameContextMenu menu, Transform overlay, float pieRadius, float innerFrac)
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = Traverse.Create(menu);
|
||||
var centerRt = t.Field<RectTransform>("centerRectTransform").Value;
|
||||
var src = t.Field<TMP_Text>("centerLabel").Value;
|
||||
if (src == null) return null;
|
||||
|
||||
float srcSize = src.fontSize > 1f ? src.fontSize : 14f;
|
||||
float fontRoad = Mathf.Clamp(srcSize, 13f, 16f);
|
||||
float fontBody = Mathf.Clamp(srcSize * 0.88f, 11.5f, 14f);
|
||||
var font = src.font;
|
||||
Material? mat = src.fontSharedMaterial;
|
||||
|
||||
var hidden = new List<Graphic>();
|
||||
HideFactory(centerRt, src, hidden);
|
||||
|
||||
float holeR = Mathf.Max(36f, pieRadius * Mathf.Clamp(innerFrac, 0.35f, 0.7f));
|
||||
float discR = Mathf.Max(28f, holeR - BorderPx - GaugeThick);
|
||||
|
||||
var root = new GameObject("S3_CenterHud", typeof(RectTransform), typeof(LayoutElement));
|
||||
var rt = (RectTransform)root.transform;
|
||||
rt.SetParent(overlay, false);
|
||||
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
rt.pivot = new Vector2(0.5f, 0.5f);
|
||||
rt.localPosition = Vector3.zero;
|
||||
rt.sizeDelta = new Vector2(holeR * 2f, holeR * 2f);
|
||||
root.GetComponent<LayoutElement>().ignoreLayout = true;
|
||||
rt.SetAsFirstSibling();
|
||||
|
||||
var disc = MakeImage(rt, "Disc", CircleSprite(), Panel, raycast: false);
|
||||
disc.rectTransform.sizeDelta = new Vector2(discR * 2f, discR * 2f);
|
||||
|
||||
var gaugeGo = new GameObject("Gauge", typeof(RectTransform));
|
||||
var gaugeRt = (RectTransform)gaugeGo.transform;
|
||||
gaugeRt.SetParent(rt, false);
|
||||
gaugeRt.anchorMin = gaugeRt.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
gaugeRt.pivot = new Vector2(0.5f, 0.5f);
|
||||
gaugeRt.localPosition = Vector3.zero;
|
||||
gaugeRt.sizeDelta = new Vector2(holeR * 2f, holeR * 2f);
|
||||
|
||||
float borderInner = (holeR - BorderPx) / holeR;
|
||||
float gaugeInner = (holeR - BorderPx - GaugeThick) / holeR;
|
||||
MakeWedge(gaugeRt, "Border", Border, borderInner, 0f, 360f);
|
||||
MakeWedge(gaugeRt, "Track", Track, gaugeInner, TrackStart(), GaugeRange);
|
||||
var weight = MakeWedge(gaugeRt, "Weight", WeightFill, gaugeInner, TrackStart(), 1f);
|
||||
|
||||
float tickLen = GaugeThick + TickInward;
|
||||
var tickMax = MakeTick(gaugeRt, "TickMax", MaxTe, TickWidth, tickLen, out Image tickMaxImg);
|
||||
var tickCur = MakeTick(gaugeRt, "TickCurrent", CurrentTe, TickWidth, tickLen, out Image tickCurImg);
|
||||
var tickHere = MakeTick(gaugeRt, "TickHere", HereTe, TickWidth, tickLen, out Image tickHereImg);
|
||||
var tickNeed = MakeTick(gaugeRt, "TickNeed", NeedTe, TickWidth, tickLen, out Image tickNeedImg);
|
||||
|
||||
float textW = discR * 1.62f;
|
||||
var road = MakeLabel(rt, "Road", font, mat, fontRoad, Fg, src.fontStyle, textW, fontRoad + 6f);
|
||||
var stats = MakeLabel(rt, "Stats", font, mat, fontBody, FgDim, FontStyles.Normal, textW, fontBody * 2.6f);
|
||||
var preview = MakeLabel(rt, "Preview", font, mat, fontBody, Fg, FontStyles.Normal, textW, fontBody * 2.8f);
|
||||
stats.gameObject.SetActive(false);
|
||||
preview.gameObject.SetActive(false);
|
||||
|
||||
return new PieCenterHud(
|
||||
gaugeRt, weight,
|
||||
tickMax, tickCur, tickHere, tickNeed,
|
||||
tickMaxImg, tickCurImg, tickHereImg, tickNeedImg,
|
||||
road, stats, preview,
|
||||
holeR, discR, fontRoad, fontBody, hidden);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void RestoreFactory()
|
||||
{
|
||||
for (int i = 0; i < _hiddenFactory.Count; i++)
|
||||
{
|
||||
if (_hiddenFactory[i] != null)
|
||||
_hiddenFactory[i].enabled = true;
|
||||
}
|
||||
_hiddenFactory.Clear();
|
||||
}
|
||||
|
||||
void KeepFactoryHidden()
|
||||
{
|
||||
for (int i = 0; i < _hiddenFactory.Count; i++)
|
||||
{
|
||||
if (_hiddenFactory[i] != null && _hiddenFactory[i].enabled)
|
||||
_hiddenFactory[i].enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Tick(string road, string? preview, TrainReadout.Snapshot? stats, bool showStats, bool showGauge)
|
||||
{
|
||||
KeepFactoryHidden();
|
||||
bool hintOnly = !string.IsNullOrEmpty(preview);
|
||||
_road.gameObject.SetActive(!hintOnly);
|
||||
if (!hintOnly)
|
||||
{
|
||||
_road.text = road;
|
||||
_road.fontSize = _fontRoad;
|
||||
FitLabel(_road, road, _fontRoad, 1);
|
||||
}
|
||||
|
||||
bool statsOn = !hintOnly && showStats && stats.HasValue;
|
||||
_stats.gameObject.SetActive(statsOn);
|
||||
if (statsOn)
|
||||
FitLabel(_stats, stats!.Value.StatsBlock(), _fontBody, 2);
|
||||
|
||||
_preview.gameObject.SetActive(hintOnly);
|
||||
if (hintOnly)
|
||||
FitLabel(_preview, preview!, _fontBody, 3);
|
||||
|
||||
if (hintOnly)
|
||||
_preview.rectTransform.localPosition = Vector3.zero;
|
||||
else
|
||||
StackText(statsOn, previewOn: false);
|
||||
|
||||
bool gaugeOn = !hintOnly && showGauge && stats.HasValue;
|
||||
_gaugeRoot.gameObject.SetActive(gaugeOn);
|
||||
if (!gaugeOn) return;
|
||||
|
||||
var s = stats.Value;
|
||||
float scale = Mathf.Max(1f, s.RatedTeLbf, s.CurrentTeLbf, s.HereLbf, s.NeedLbf, s.WeightMarkLbf);
|
||||
SetArc(_weight, s.WeightMarkLbf / scale);
|
||||
|
||||
PlaceTick(_tickMax, s.RatedTeLbf / scale);
|
||||
PlaceTick(_tickCurrent, s.CurrentTeLbf / scale);
|
||||
PlaceTick(_tickHere, s.HereLbf / scale);
|
||||
bool route = s.HasWaypoint && s.NeedLbf > 0.5f;
|
||||
_tickNeed.gameObject.SetActive(route);
|
||||
if (route)
|
||||
PlaceTick(_tickNeed, s.NeedLbf / scale);
|
||||
|
||||
_tickMaxImg.color = MaxTe;
|
||||
_tickCurrentImg.color = CurrentTe;
|
||||
_tickHereImg.color = HereTe;
|
||||
_tickNeedImg.color = NeedTe;
|
||||
}
|
||||
|
||||
void StackText(bool statsOn, bool previewOn)
|
||||
{
|
||||
float roadH = _road.rectTransform.sizeDelta.y;
|
||||
float statsH = statsOn ? _stats.rectTransform.sizeDelta.y : 0f;
|
||||
float prevH = previewOn ? _preview.rectTransform.sizeDelta.y : 0f;
|
||||
float gap = 5f;
|
||||
float total = roadH + (statsOn ? gap + statsH : 0f) + (previewOn ? gap + prevH : 0f);
|
||||
float y = total * 0.5f - roadH * 0.5f;
|
||||
_road.rectTransform.localPosition = new Vector3(0f, y, 0f);
|
||||
y -= roadH * 0.5f;
|
||||
if (statsOn)
|
||||
{
|
||||
y -= gap + statsH * 0.5f;
|
||||
_stats.rectTransform.localPosition = new Vector3(0f, y, 0f);
|
||||
y -= statsH * 0.5f;
|
||||
}
|
||||
if (previewOn)
|
||||
{
|
||||
y -= gap + prevH * 0.5f;
|
||||
_preview.rectTransform.localPosition = new Vector3(0f, y, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
void FitLabel(TMP_Text tmp, string text, float size, int maxLines)
|
||||
{
|
||||
tmp.enableAutoSizing = false;
|
||||
tmp.fontSize = size;
|
||||
tmp.text = text;
|
||||
tmp.lineSpacing = 2f;
|
||||
float maxW = _discR * 1.62f;
|
||||
tmp.ForceMeshUpdate();
|
||||
Vector2 pref = tmp.GetPreferredValues(text, maxW, size * (maxLines * 1.35f + 0.4f));
|
||||
float h = Mathf.Clamp(pref.y, size * 1.15f, size * maxLines * 1.4f);
|
||||
float w = Mathf.Min(maxW, Mathf.Max(24f, pref.x));
|
||||
tmp.rectTransform.sizeDelta = new Vector2(w, h);
|
||||
}
|
||||
|
||||
static float TrackStart() => Mathf.Repeat(GaugeMin - GaugeRange, 360f);
|
||||
|
||||
static float ClockwiseDeg(float t) => Mathf.Repeat(GaugeMin - Mathf.Clamp01(t) * GaugeRange, 360f);
|
||||
|
||||
void SetArc(WedgeImage wedge, float t)
|
||||
{
|
||||
float span = Mathf.Clamp01(t) * GaugeRange;
|
||||
wedge.startAngle = ClockwiseDeg(t);
|
||||
wedge.angleRange = span;
|
||||
wedge.SetVerticesDirty();
|
||||
}
|
||||
|
||||
void PlaceTick(RectTransform tick, float t)
|
||||
{
|
||||
float deg = ClockwiseDeg(t);
|
||||
float rad = deg * Mathf.Deg2Rad;
|
||||
float r = _holeR - BorderPx;
|
||||
tick.localPosition = new Vector3(Mathf.Cos(rad) * r, Mathf.Sin(rad) * r, 0f);
|
||||
tick.localRotation = Quaternion.Euler(0f, 0f, deg - 90f);
|
||||
tick.gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
static void HideFactory(RectTransform? centerRt, TMP_Text src, List<Graphic> hidden)
|
||||
{
|
||||
void Hide(Graphic g)
|
||||
{
|
||||
if (g == null || !g.enabled) return;
|
||||
g.enabled = false;
|
||||
hidden.Add(g);
|
||||
}
|
||||
|
||||
Hide(src);
|
||||
if (centerRt == null) return;
|
||||
foreach (var g in centerRt.GetComponentsInChildren<Graphic>(true))
|
||||
Hide(g);
|
||||
}
|
||||
|
||||
static TMP_Text MakeLabel(
|
||||
RectTransform parent, string name, TMP_FontAsset? font, Material? mat,
|
||||
float size, Color color, FontStyles style, float w, float h)
|
||||
{
|
||||
var go = new GameObject(name, typeof(RectTransform), typeof(TextMeshProUGUI));
|
||||
var rt = (RectTransform)go.transform;
|
||||
rt.SetParent(parent, false);
|
||||
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
rt.pivot = new Vector2(0.5f, 0.5f);
|
||||
rt.sizeDelta = new Vector2(w, h);
|
||||
var tmp = go.GetComponent<TextMeshProUGUI>();
|
||||
tmp.enableAutoSizing = false;
|
||||
tmp.fontSize = size;
|
||||
tmp.alignment = TextAlignmentOptions.Center;
|
||||
tmp.color = color;
|
||||
tmp.fontStyle = style;
|
||||
tmp.raycastTarget = false;
|
||||
tmp.overflowMode = TextOverflowModes.Truncate;
|
||||
tmp.textWrappingMode = TextWrappingModes.Normal;
|
||||
tmp.lineSpacing = 2f;
|
||||
if (font != null) tmp.font = font;
|
||||
if (mat != null) tmp.fontSharedMaterial = mat;
|
||||
return tmp;
|
||||
}
|
||||
|
||||
static Image MakeImage(RectTransform parent, string name, Sprite sprite, Color color, bool raycast)
|
||||
{
|
||||
var go = new GameObject(name, typeof(RectTransform), typeof(Image));
|
||||
var rt = (RectTransform)go.transform;
|
||||
rt.SetParent(parent, false);
|
||||
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
rt.pivot = new Vector2(0.5f, 0.5f);
|
||||
var img = go.GetComponent<Image>();
|
||||
img.sprite = sprite;
|
||||
img.color = color;
|
||||
img.raycastTarget = raycast;
|
||||
img.preserveAspect = true;
|
||||
return img;
|
||||
}
|
||||
|
||||
static WedgeImage MakeWedge(RectTransform parent, string name, Color color, float innerFrac, float start, float range)
|
||||
{
|
||||
var go = new GameObject(name, typeof(RectTransform), typeof(WedgeImage));
|
||||
var rt = (RectTransform)go.transform;
|
||||
rt.SetParent(parent, false);
|
||||
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
rt.pivot = new Vector2(0.5f, 0.5f);
|
||||
rt.sizeDelta = parent.sizeDelta;
|
||||
var w = go.GetComponent<WedgeImage>();
|
||||
w.sprite = WhiteSprite();
|
||||
w.color = color;
|
||||
w.raycastTarget = false;
|
||||
w.innerRadius = innerFrac;
|
||||
w.startAngle = start;
|
||||
w.angleRange = range;
|
||||
return w;
|
||||
}
|
||||
|
||||
static RectTransform MakeTick(RectTransform parent, string name, Color color, float width, float length, out Image img)
|
||||
{
|
||||
img = MakeImage(parent, name, WhiteSprite(), color, raycast: false);
|
||||
img.preserveAspect = false;
|
||||
var rt = img.rectTransform;
|
||||
rt.pivot = new Vector2(0.5f, 1f);
|
||||
rt.sizeDelta = new Vector2(width, length);
|
||||
return rt;
|
||||
}
|
||||
|
||||
static Sprite WhiteSprite()
|
||||
{
|
||||
if (_white != null) return _white;
|
||||
var tex = Texture2D.whiteTexture;
|
||||
_white = Sprite.Create(tex, new Rect(0f, 0f, tex.width, tex.height), new Vector2(0.5f, 0.5f), 4f);
|
||||
return _white;
|
||||
}
|
||||
|
||||
static Sprite CircleSprite()
|
||||
{
|
||||
if (_circle != null) return _circle;
|
||||
const int radius = 16;
|
||||
int size = radius * 2;
|
||||
var tex = new Texture2D(size, size, TextureFormat.RGBA32, mipChain: false);
|
||||
tex.filterMode = FilterMode.Bilinear;
|
||||
var pixels = new Color32[size * size];
|
||||
float c = radius - 0.5f;
|
||||
for (int y = 0; y < size; y++)
|
||||
for (int x = 0; x < size; x++)
|
||||
{
|
||||
float dist = Mathf.Sqrt((x - c) * (x - c) + (y - c) * (y - c));
|
||||
byte a = (byte)(Mathf.Clamp01(radius - dist) * 255f);
|
||||
pixels[y * size + x] = new Color32(255, 255, 255, a);
|
||||
}
|
||||
tex.SetPixels32(pixels);
|
||||
tex.Apply(updateMipmaps: false, makeNoLongerReadable: true);
|
||||
_circle = Sprite.Create(tex, new Rect(0f, 0f, size, size), new Vector2(0.5f, 0.5f), 100f);
|
||||
return _circle;
|
||||
}
|
||||
}
|
||||
79
src/Modules/QuickActions/QuickActionsModule.cs
Normal file
79
src/Modules/QuickActions/QuickActionsModule.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using System;
|
||||
using HarmonyLib;
|
||||
using S3.Core;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.QuickActions;
|
||||
|
||||
public sealed class QuickActionsModule : IModule
|
||||
{
|
||||
private const string SettingsFile = "S3.quickactions.json";
|
||||
|
||||
public static QuickActionsSettings Settings { get; private set; } = new();
|
||||
|
||||
private static Harmony? _harmony;
|
||||
private static GameObject? _hostGo;
|
||||
internal static QuickActionsHost? Host { get; private set; }
|
||||
|
||||
private static readonly Type[] PatchTypes =
|
||||
{
|
||||
typeof(CarPickableContextMenuPatch),
|
||||
typeof(ContextMenuEvenLayoutPatch),
|
||||
typeof(ContextMenuItemSetAnglePatch),
|
||||
typeof(ContextMenuShowPatch),
|
||||
typeof(ContextMenuHidePatch),
|
||||
};
|
||||
|
||||
public QuickActionsModule() => Settings = SettingsStore.Load<QuickActionsSettings>(SettingsFile);
|
||||
|
||||
public string Id => "quickactions";
|
||||
public string DisplayName => "Quick Actions";
|
||||
public string Description =>
|
||||
"Outer-ring Coupler / Air Line / Anglecock / Cut on rolling stock, plus a Consist hover wheel " +
|
||||
"on any car (Set Lead, handbrakes, bleed, anglecocks, air, idle, select lead/loco).";
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get => Settings.enabled;
|
||||
set => Settings.enabled = value;
|
||||
}
|
||||
|
||||
public void OnEnable()
|
||||
{
|
||||
_harmony = new Harmony("S3.quickactions");
|
||||
foreach (Type t in PatchTypes)
|
||||
{
|
||||
try
|
||||
{
|
||||
_harmony.CreateClassProcessor(t).Patch();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error($"[quickactions] patch {t.Name} failed: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_hostGo = new GameObject("[S3] QuickActionsHost");
|
||||
UnityEngine.Object.DontDestroyOnLoad(_hostGo);
|
||||
Host = _hostGo.AddComponent<QuickActionsHost>();
|
||||
}
|
||||
|
||||
public void OnDisable()
|
||||
{
|
||||
_harmony?.UnpatchAll("S3.quickactions");
|
||||
_harmony = null;
|
||||
EndGearOverlay.Detach();
|
||||
Host = null;
|
||||
if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo);
|
||||
_hostGo = null;
|
||||
}
|
||||
|
||||
public void SaveSettings() => Persist();
|
||||
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
|
||||
|
||||
public void DrawSettings() => QuickActionsSettingsUI.Draw();
|
||||
}
|
||||
|
||||
public sealed class QuickActionsHost : MonoBehaviour
|
||||
{
|
||||
}
|
||||
34
src/Modules/QuickActions/QuickActionsSettings.cs
Normal file
34
src/Modules/QuickActions/QuickActionsSettings.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using System;
|
||||
|
||||
namespace S3.Modules.QuickActions;
|
||||
|
||||
[Serializable]
|
||||
public class QuickActionsSettings
|
||||
{
|
||||
public bool enabled = false;
|
||||
|
||||
public bool dropHandbrakeOnCut = false;
|
||||
public bool dropHandbrakeExcludeLocos = true;
|
||||
|
||||
public bool consistSetLead = true;
|
||||
public bool consistReleaseHandbrakes = true;
|
||||
public bool consistReleaseHandbrakesExcludeLocos = false;
|
||||
public bool consistApplyHandbrakes = true;
|
||||
public bool consistApplyHandbrakesExcludeLocos = true;
|
||||
public bool consistBleedAll = true;
|
||||
public bool consistBleedAllExcludeLocos = true;
|
||||
public bool consistOpenCocks = true;
|
||||
public bool consistOpenCocksExcludeLocos = false;
|
||||
public bool consistCloseCocks = true;
|
||||
public bool consistCloseCocksExcludeLocos = false;
|
||||
public bool consistConnectAir = true;
|
||||
public bool consistConnectAirExcludeLocos = false;
|
||||
public bool consistIdleBail = true;
|
||||
public bool consistSelectLead = true;
|
||||
public bool consistSelectLoco = true;
|
||||
|
||||
public bool centerActionPreview = false;
|
||||
public bool centerTrainStats = false;
|
||||
public bool centerHudAlways = false;
|
||||
public bool centerWaypointBar = false;
|
||||
}
|
||||
87
src/Modules/QuickActions/QuickActionsSettingsUI.cs
Normal file
87
src/Modules/QuickActions/QuickActionsSettingsUI.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.QuickActions;
|
||||
|
||||
static class QuickActionsSettingsUI
|
||||
{
|
||||
public static void Draw()
|
||||
{
|
||||
var s = QuickActionsModule.Settings;
|
||||
bool changed = false;
|
||||
|
||||
GUILayout.BeginVertical();
|
||||
GUILayout.Label("<b>Quick Actions</b> - extra items on the rolling-stock radial menu");
|
||||
GUILayout.Space(4f);
|
||||
GUILayout.Label(
|
||||
" Couple / Uncouple, Attach / Detach, and Open / Close Anglecock sit on an outer ring at each end.\n" +
|
||||
" Cut closes anglecocks, detaches the hose, and uncouples that joint.\n" +
|
||||
" Hover Consist on any car for train-wide actions that apply to this cut.",
|
||||
GUI.skin.label);
|
||||
|
||||
GUILayout.Space(10f);
|
||||
GUILayout.Label("<b>Cut</b>");
|
||||
changed |= Toggle(ref s.dropHandbrakeOnCut,
|
||||
" After Cut, apply handbrakes on the cut that left your train");
|
||||
if (s.dropHandbrakeOnCut)
|
||||
{
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Space(18f);
|
||||
changed |= Toggle(ref s.dropHandbrakeExcludeLocos, " Exclude locomotives");
|
||||
GUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
GUILayout.Space(10f);
|
||||
GUILayout.Label("<b>Consist wheel</b> (hover Consist; each action only appears when it can do something)");
|
||||
GUILayout.Space(4f);
|
||||
changed |= Toggle(ref s.consistSetLead, " Set Lead (clicked locomotive in a cut with two or more locomotives)");
|
||||
changed |= ActionRow("Release all handbrakes", ref s.consistReleaseHandbrakes, ref s.consistReleaseHandbrakesExcludeLocos);
|
||||
changed |= ActionRow("Apply all handbrakes", ref s.consistApplyHandbrakes, ref s.consistApplyHandbrakesExcludeLocos);
|
||||
changed |= ActionRow("Bleed all", ref s.consistBleedAll, ref s.consistBleedAllExcludeLocos);
|
||||
changed |= ActionRow("Open all anglecocks", ref s.consistOpenCocks, ref s.consistOpenCocksExcludeLocos);
|
||||
changed |= ActionRow("Close all anglecocks", ref s.consistCloseCocks, ref s.consistCloseCocksExcludeLocos);
|
||||
changed |= ActionRow("Attach all hoses", ref s.consistConnectAir, ref s.consistConnectAirExcludeLocos);
|
||||
changed |= Toggle(ref s.consistIdleBail, " Idle throttle and bail independents (when the cut has a locomotive)");
|
||||
changed |= Toggle(ref s.consistSelectLead, " Select Lead (when you are not already on the lead of a multi-loco cut)");
|
||||
changed |= Toggle(ref s.consistSelectLoco, " Select Loco (when you clicked a car and the cut has exactly one locomotive)");
|
||||
|
||||
GUILayout.Space(10f);
|
||||
GUILayout.Label("<b>Center readout</b> (replaces the reporting-mark hole; all extras off by default)");
|
||||
GUILayout.Space(4f);
|
||||
changed |= Toggle(ref s.centerActionPreview, " Hover preview (counts and which locomotive; skipped when the button already says it)");
|
||||
changed |= Toggle(ref s.centerTrainStats, " Train length and weight");
|
||||
changed |= Toggle(ref s.centerWaypointBar, " Power gauge (weight fills the ring; gold = max TE, cyan = current, orange = this grade, red = waypoint grade)");
|
||||
if (s.centerTrainStats || s.centerWaypointBar)
|
||||
{
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Space(18f);
|
||||
changed |= Toggle(ref s.centerHudAlways, " Show even when not hovering a button");
|
||||
GUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
GUILayout.EndVertical();
|
||||
|
||||
if (changed)
|
||||
QuickActionsModule.Persist();
|
||||
}
|
||||
|
||||
static bool ActionRow(string label, ref bool enabled, ref bool excludeLocos)
|
||||
{
|
||||
bool changed = Toggle(ref enabled, " " + label);
|
||||
if (enabled)
|
||||
{
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.Space(18f);
|
||||
changed |= Toggle(ref excludeLocos, " Exclude locomotives");
|
||||
GUILayout.EndHorizontal();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
static bool Toggle(ref bool field, string label)
|
||||
{
|
||||
bool next = GUILayout.Toggle(field, label);
|
||||
if (next == field) return false;
|
||||
field = next;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
55
src/Modules/QuickActions/SetLeadIconFacing.cs
Normal file
55
src/Modules/QuickActions/SetLeadIconFacing.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
using Model;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace S3.Modules.QuickActions;
|
||||
|
||||
/// <summary>
|
||||
/// Screen-space heading of a car vs the camera: +1 nose-right, -1 nose-left.
|
||||
/// Same test the couple/air hints will use to park on opposite sides of the pie.
|
||||
/// When the pie is opened from the map, <see cref="CameraOverride"/> is the map
|
||||
/// camera so arrows follow map rotation instead of the player view.
|
||||
/// </summary>
|
||||
static class CarScreenFacing
|
||||
{
|
||||
internal static Camera? CameraOverride;
|
||||
|
||||
public static Camera? Active() => CameraOverride != null ? CameraOverride : Camera.main;
|
||||
|
||||
public static float Sign(Car? car)
|
||||
{
|
||||
if (car == null) return 1f;
|
||||
Camera? cam = Active();
|
||||
Transform? body = car.BodyTransform != null ? car.BodyTransform : car.transform;
|
||||
if (cam == null || body == null) return 1f;
|
||||
return Vector3.Dot(cam.transform.right, body.forward) >= 0f ? 1f : -1f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the Consist glyph sized in the pie slot. Vanilla hover/click LeanTweens
|
||||
/// localScale to 1.1 / 1.3 / 1.0; we preserve that magnitude.
|
||||
/// </summary>
|
||||
sealed class SetLeadIconFacing : MonoBehaviour
|
||||
{
|
||||
public Car? Car;
|
||||
public Image? Image;
|
||||
public Vector2 TargetSize = new Vector2(40f, 40f);
|
||||
public bool FlipFacing = false;
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
if (Image == null) return;
|
||||
RectTransform rt = Image.rectTransform;
|
||||
rt.anchorMin = rt.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
rt.pivot = new Vector2(0.5f, 0.5f);
|
||||
Image.preserveAspect = true;
|
||||
Image.raycastTarget = false;
|
||||
rt.sizeDelta = TargetSize;
|
||||
|
||||
float mag = Mathf.Abs(rt.localScale.y);
|
||||
if (mag < 0.01f) mag = 1f;
|
||||
float sign = FlipFacing ? CarScreenFacing.Sign(Car) : 1f;
|
||||
rt.localScale = new Vector3(sign * mag, mag, mag);
|
||||
}
|
||||
}
|
||||
169
src/Modules/QuickActions/TrainReadout.cs
Normal file
169
src/Modules/QuickActions/TrainReadout.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
using System.Collections.Generic;
|
||||
using Game.Messages;
|
||||
using HarmonyLib;
|
||||
using Model;
|
||||
using Model.AI;
|
||||
using Track.Search;
|
||||
using UnityEngine;
|
||||
|
||||
namespace S3.Modules.QuickActions;
|
||||
|
||||
static class TrainReadout
|
||||
{
|
||||
public readonly struct Snapshot
|
||||
{
|
||||
public readonly int Cars;
|
||||
public readonly float LengthFt;
|
||||
public readonly float Tons;
|
||||
public readonly float RatedTeLbf;
|
||||
public readonly float CurrentTeLbf;
|
||||
public readonly float HereLbf;
|
||||
public readonly float NeedLbf;
|
||||
public readonly float WeightMarkLbf;
|
||||
public readonly bool HasWaypoint;
|
||||
public readonly bool CanMakeIt;
|
||||
|
||||
public Snapshot(
|
||||
int cars, float lengthFt, float tons,
|
||||
float ratedTeLbf, float currentTeLbf, float hereLbf, float needLbf, float weightMarkLbf,
|
||||
bool hasWaypoint, bool canMakeIt)
|
||||
{
|
||||
Cars = cars;
|
||||
LengthFt = lengthFt;
|
||||
Tons = tons;
|
||||
RatedTeLbf = ratedTeLbf;
|
||||
CurrentTeLbf = currentTeLbf;
|
||||
HereLbf = hereLbf;
|
||||
NeedLbf = needLbf;
|
||||
WeightMarkLbf = weightMarkLbf;
|
||||
HasWaypoint = hasWaypoint;
|
||||
CanMakeIt = canMakeIt;
|
||||
}
|
||||
|
||||
public string StatsBlock()
|
||||
{
|
||||
return $"{LengthFt:0} ft\n{Tons:0} T";
|
||||
}
|
||||
|
||||
public static string FormatTe(float lbf)
|
||||
{
|
||||
if (lbf >= 1000f) return $"{lbf / 1000f:0.0}k lbf";
|
||||
return $"{lbf:0} lbf";
|
||||
}
|
||||
}
|
||||
|
||||
public static Snapshot Measure(Car origin)
|
||||
{
|
||||
int cars = 0;
|
||||
float meters = 0f;
|
||||
float pounds = 0f;
|
||||
float rated = 0f;
|
||||
float current = 0f;
|
||||
float gravity = 0f;
|
||||
BaseLocomotive? loco = null;
|
||||
try
|
||||
{
|
||||
foreach (Car c in origin.EnumerateCoupled())
|
||||
{
|
||||
cars++;
|
||||
meters += c.carLength;
|
||||
pounds += c.Weight;
|
||||
gravity += c.GravityForce;
|
||||
if (c is BaseLocomotive l)
|
||||
{
|
||||
rated += l.RatedTractiveEffort;
|
||||
current += Mathf.Abs(l.TractiveEffort);
|
||||
loco ??= l;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* measured what we could */ }
|
||||
|
||||
float tons = pounds / 2000f;
|
||||
float weightMark = tons * 20f;
|
||||
float here = Mathf.Abs(gravity);
|
||||
float need = 0f;
|
||||
bool hasWp = false;
|
||||
if (TryWaypointNeed(origin, loco, tons, out float routeNeed, out bool wp) && wp)
|
||||
{
|
||||
hasWp = true;
|
||||
need = routeNeed;
|
||||
}
|
||||
|
||||
bool can = rated + 0.5f >= Mathf.Max(here, need);
|
||||
return new Snapshot(
|
||||
cars, meters * 3.28084f, tons,
|
||||
rated, current, here, need, weightMark,
|
||||
hasWp, can);
|
||||
}
|
||||
|
||||
static bool TryWaypointNeed(Car origin, BaseLocomotive? first, float tons, out float need, out bool hasWaypoint)
|
||||
{
|
||||
need = 0f;
|
||||
hasWaypoint = false;
|
||||
try
|
||||
{
|
||||
foreach (Car c in origin.EnumerateCoupled())
|
||||
{
|
||||
if (c is not BaseLocomotive l) continue;
|
||||
var planner = l.AutoEngineerPlanner;
|
||||
if (planner == null) continue;
|
||||
object? raw = Traverse.Create(planner).Field("_orders").GetValue();
|
||||
if (raw is not Orders orders) continue;
|
||||
if (orders.Mode != AutoEngineerMode.Waypoint || !orders.Waypoint.HasValue)
|
||||
continue;
|
||||
hasWaypoint = true;
|
||||
if (TryRouteGrade(planner, out float gradePct))
|
||||
need = tons * 20f * Mathf.Max(0f, gradePct);
|
||||
else
|
||||
need = Mathf.Abs(first != null ? SumGravity(origin) : 0f);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { /* no waypoint */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
static float SumGravity(Car origin)
|
||||
{
|
||||
float n = 0f;
|
||||
try
|
||||
{
|
||||
foreach (Car c in origin.EnumerateCoupled())
|
||||
n += c.GravityForce;
|
||||
}
|
||||
catch { /* */ }
|
||||
return n;
|
||||
}
|
||||
|
||||
static bool TryRouteGrade(AutoEngineerPlanner planner, out float maxAdversePct)
|
||||
{
|
||||
maxAdversePct = 0f;
|
||||
try
|
||||
{
|
||||
object? raw = Traverse.Create(planner).Field("_route").GetValue();
|
||||
if (raw is not List<RouteSearch.Step> route || route.Count < 2)
|
||||
return false;
|
||||
Vector3 prev = route[0].Position;
|
||||
for (int i = 1; i < route.Count; i++)
|
||||
{
|
||||
Vector3 p = route[i].Position;
|
||||
Vector3 d = p - prev;
|
||||
float horiz = new Vector2(d.x, d.z).magnitude;
|
||||
if (horiz < 0.5f)
|
||||
{
|
||||
prev = p;
|
||||
continue;
|
||||
}
|
||||
float pct = d.y / horiz * 100f;
|
||||
if (pct > maxAdversePct) maxAdversePct = pct;
|
||||
prev = p;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,18 @@
|
|||
<HintPath>$(GameManaged)\Map.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Definition">
|
||||
<HintPath>$(GameManaged)\Definition.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="VisualDesignCafe.Rendering.Nature">
|
||||
<HintPath>$(GameManaged)\VisualDesignCafe.Rendering.Nature.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="VisualDesignCafe.Rendering.Instancing">
|
||||
<HintPath>$(GameManaged)\VisualDesignCafe.Rendering.Instancing.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Unity -->
|
||||
|
|
@ -49,6 +61,10 @@
|
|||
<HintPath>$(GameManaged)\UnityEngine.PhysicsModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.TerrainModule">
|
||||
<HintPath>$(GameManaged)\UnityEngine.TerrainModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.JSONSerializeModule">
|
||||
<HintPath>$(GameManaged)\UnityEngine.JSONSerializeModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
|
|
@ -65,6 +81,10 @@
|
|||
<HintPath>$(GameManaged)\UnityEngine.InputLegacyModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Unity.InputSystem">
|
||||
<HintPath>$(GameManaged)\Unity.InputSystem.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Unity.TextMeshPro">
|
||||
<HintPath>$(GameManaged)\Unity.TextMeshPro.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
|
|
@ -73,6 +93,14 @@
|
|||
<HintPath>$(GameManaged)\Unity.RenderPipelines.Universal.Runtime.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.ImageConversionModule">
|
||||
<HintPath>$(GameManaged)\UnityEngine.ImageConversionModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.TextRenderingModule">
|
||||
<HintPath>$(GameManaged)\UnityEngine.TextRenderingModule.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- UMM + Harmony -->
|
||||
|
|
@ -85,6 +113,16 @@
|
|||
<HintPath>$(UmmDir)\0Harmony.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Newtonsoft.Json">
|
||||
<HintPath>$(GameManaged)\Newtonsoft.Json.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Modules\QuickActions\Icons\consist.png">
|
||||
<LogicalName>S3.QuickActions.consist.png</LogicalName>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
Loading…
Reference in a new issue