Map: view presets, waypoint pins, and TrackLabelService
Named camera bookmarks, Auto Engineer / WaypointQueue pins, and extracted track labels. Stock MapWindow stays collapsed while S3 owns the camera. Radio Runner is not in this drop.
This commit is contained in:
parent
290211e9e8
commit
9f2097e579
21 changed files with 3721 additions and 751 deletions
|
|
@ -16,7 +16,7 @@ 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. |
|
||||
| Base Game Performance | Smooths Unity's incremental garbage collector and Nature Renderer grass streaming to reduce camera-motion hitches without lowering visual quality. |
|
||||
|
|
@ -91,6 +91,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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
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
|
|
@ -42,6 +42,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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue