Created
June 24, 2026 10:28
-
-
Save TTSKarlsson/bd3e37ca5f10d0aa0f53002cb9ef463d to your computer and use it in GitHub Desktop.
G1R Compass UI Patched 1.1.12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| local MOD_NAME = "[G1R_CompassUI]" | |
| local VERSION = "1.0.12" | |
| local SCRIPT_DIR = (debug.getinfo(1, "S").source or ""):match("^@?(.*[\\/])") or "" | |
| local SCRIPT_SEP = string.find(SCRIPT_DIR, "\\", 1, true) and "\\" or "/" | |
| local DEBUG_LOG = SCRIPT_DIR .. ".." .. SCRIPT_SEP .. "compass_debug.log" | |
| local CONFIG = { | |
| startupDelayMs = 12000, | |
| retryDelayMs = 3000, | |
| postLoadDelayMs = 3000, | |
| updateDelayMs = 100, | |
| usePlayerTickHook = false, | |
| useClientRestartHook = false, | |
| x = 0, | |
| markerY = 1, | |
| labelY = 16, | |
| width = 96, | |
| height = 28, | |
| markerFontSize = 12, | |
| labelFontSize = 13, | |
| textColor = { R = 0.78, G = 0.72, B = 0.58, A = 0.78 }, | |
| labelSpacing = 92, | |
| markerRange = 92, | |
| yawOffsetDegrees = -90, | |
| useCenterAnchor = true, | |
| maxDebugLines = 300, | |
| tag = "G1R_COMPASS_UI", | |
| } | |
| local state = { | |
| marker = nil, | |
| labelWidgets = {}, | |
| labelSlots = {}, | |
| createdWidgets = {}, | |
| markerSlot = nil, | |
| centerX = nil, | |
| ready = false, | |
| loopStarted = false, | |
| nextCreateMs = 0, | |
| lastText = nil, | |
| lastLogMs = 0, | |
| lastMoveLogMs = 0, | |
| loopSerial = 0, | |
| lastYaw = nil, | |
| lastYawSource = "none", | |
| tickHooked = false, | |
| parentName = nil, | |
| mainMapContext = nil, | |
| suspendedUntilMs = 0, | |
| creating = false, | |
| loadGeneration = 0, | |
| centerAnchorSupported = nil, | |
| debugLineCount = 0, | |
| } | |
| local labels = { | |
| "N", "NNE", "NE", "ENE", | |
| "E", "ESE", "SE", "SSE", | |
| "S", "SSW", "SW", "WSW", | |
| "W", "WNW", "NW", "NNW", | |
| } | |
| local function reset_log() | |
| pcall(function() | |
| local file = io.open(DEBUG_LOG, "w") | |
| if file then | |
| file:write(os.date("%Y-%m-%d %H:%M:%S ") .. MOD_NAME .. " debug log reset v" .. VERSION .. "\n") | |
| file:close() | |
| state.debugLineCount = 1 | |
| end | |
| end) | |
| end | |
| local function log(message) | |
| if state.debugLineCount >= CONFIG.maxDebugLines then | |
| return | |
| end | |
| local ok, file = pcall(function() | |
| return io.open(DEBUG_LOG, "a") | |
| end) | |
| if ok and file then | |
| file:write(os.date("%Y-%m-%d %H:%M:%S ") .. MOD_NAME .. " " .. tostring(message) .. "\n") | |
| file:close() | |
| state.debugLineCount = state.debugLineCount + 1 | |
| end | |
| end | |
| local function is_valid(object) | |
| if not object then | |
| return false | |
| end | |
| local ok, value = pcall(function() | |
| return object:IsValid() | |
| end) | |
| if ok then | |
| return value == true | |
| end | |
| return pcall(function() | |
| object:GetFullName() | |
| end) | |
| end | |
| local function safe_get_property(object, name) | |
| if not is_valid(object) then | |
| return nil | |
| end | |
| local ok, value = pcall(function() | |
| return object[name] | |
| end) | |
| if ok then | |
| return value | |
| end | |
| return nil | |
| end | |
| local function full_name(object) | |
| if not is_valid(object) then | |
| return "<invalid>" | |
| end | |
| local ok, name = pcall(function() | |
| return object:GetFullName() | |
| end) | |
| if ok and name then | |
| return tostring(name) | |
| end | |
| return "<unnamed>" | |
| end | |
| local function safe_static_find(path) | |
| local ok, object = pcall(function() | |
| return StaticFindObject(path) | |
| end) | |
| if ok and is_valid(object) then | |
| return object | |
| end | |
| return nil | |
| end | |
| local function safe_find_first(class_name) | |
| local ok, object = pcall(function() | |
| return FindFirstOf(class_name) | |
| end) | |
| if ok and is_valid(object) then | |
| return object | |
| end | |
| return nil | |
| end | |
| local function safe_find_all(class_name) | |
| local ok, objects = pcall(function() | |
| return FindAllOf(class_name) | |
| end) | |
| if ok and type(objects) == "table" then | |
| return objects | |
| end | |
| return {} | |
| end | |
| local function vec2(x, y) | |
| return { X = x, Y = y } | |
| end | |
| local function anchors(minX, minY, maxX, maxY) | |
| return { | |
| Minimum = vec2(minX, minY), | |
| Maximum = vec2(maxX, maxY), | |
| } | |
| end | |
| local function now_ms() | |
| return math.floor(os.clock() * 1000) | |
| end | |
| local function get_controller() | |
| local preferred = safe_find_first("GothicPlayerControllerBaseBP_C") | |
| or safe_find_first("PlayerController") | |
| if is_valid(preferred) and is_valid(safe_get_property(preferred, "Pawn")) then | |
| return preferred | |
| end | |
| for _, class_name in ipairs({ "GothicPlayerControllerBaseBP_C", "PlayerController" }) do | |
| for _, controller in ipairs(safe_find_all(class_name)) do | |
| if is_valid(controller) and is_valid(safe_get_property(controller, "Pawn")) then | |
| return controller | |
| end | |
| end | |
| end | |
| return preferred | |
| end | |
| local function in_main_map() | |
| local controller = get_controller() | |
| local controllerName = full_name(controller) | |
| if string.find(controllerName, "None.None", 1, true) then | |
| return false, controllerName | |
| end | |
| if string.find(controllerName, "MainMap", 1, true) then | |
| return true, controllerName | |
| end | |
| return false, controllerName | |
| end | |
| local function read_number_field(value, names) | |
| if value == nil then | |
| return nil | |
| end | |
| for _, name in ipairs(names) do | |
| local ok, number = pcall(function() | |
| return value[name] | |
| end) | |
| if ok and type(number) == "number" then | |
| return number | |
| end | |
| end | |
| return nil | |
| end | |
| local function get_compass_center_x() | |
| if CONFIG.useCenterAnchor and state.centerAnchorSupported == true then | |
| return 0 | |
| end | |
| local controller = get_controller() | |
| if is_valid(controller) then | |
| local ok, width = pcall(function() | |
| return controller:GetViewportSize() | |
| end) | |
| if ok and type(width) == "number" and width > 100 then | |
| return math.floor(width / 2) | |
| end | |
| if ok then | |
| local x = read_number_field(width, { "X", "x", "Width", "width" }) | |
| if x and x > 100 then | |
| return math.floor(x / 2) | |
| end | |
| end | |
| end | |
| local layoutLibrary = safe_static_find("/Script/UMG.Default__WidgetLayoutLibrary") | |
| if is_valid(layoutLibrary) and is_valid(controller) then | |
| local ok, size = pcall(function() | |
| return layoutLibrary:GetViewportSize(controller) | |
| end) | |
| if ok then | |
| local x = read_number_field(size, { "X", "x", "Width", "width" }) | |
| if x and x > 100 then | |
| return math.floor(x / 2) | |
| end | |
| end | |
| end | |
| return CONFIG.x | |
| end | |
| local function read_yaw_from_rotator(rotator) | |
| if not rotator then | |
| return nil | |
| end | |
| for _, field in ipairs({ "Yaw", "yaw", "Z", "z" }) do | |
| local ok, value = pcall(function() | |
| return rotator[field] | |
| end) | |
| if ok and type(value) == "number" then | |
| return value | |
| end | |
| end | |
| return nil | |
| end | |
| local function read_yaw_from_vector(vector) | |
| if not vector then | |
| return nil | |
| end | |
| local okX, x = pcall(function() | |
| return vector.X or vector.x | |
| end) | |
| local okY, y = pcall(function() | |
| return vector.Y or vector.y | |
| end) | |
| if okX and okY and type(x) == "number" and type(y) == "number" then | |
| return math.deg(math.atan(y, x)) | |
| end | |
| return nil | |
| end | |
| local function read_xy_from_vector(vector) | |
| if not vector then | |
| return nil, nil | |
| end | |
| local okX, x = pcall(function() | |
| return vector.X or vector.x | |
| end) | |
| local okY, y = pcall(function() | |
| return vector.Y or vector.y | |
| end) | |
| if okX and okY and type(x) == "number" and type(y) == "number" then | |
| return x, y | |
| end | |
| return nil, nil | |
| end | |
| local function read_actor_location_xy(object) | |
| if not is_valid(object) then | |
| return nil, nil | |
| end | |
| local okLocation, location = pcall(function() | |
| return object:GetActorLocation() | |
| end) | |
| if okLocation then | |
| return read_xy_from_vector(location) | |
| end | |
| return nil, nil | |
| end | |
| local function read_camera_look_yaw(manager) | |
| if not is_valid(manager) then | |
| return nil | |
| end | |
| local okCameraLocation, cameraLocation = pcall(function() | |
| return manager:GetCameraLocation() | |
| end) | |
| if not okCameraLocation then | |
| return nil | |
| end | |
| local cameraX, cameraY = read_xy_from_vector(cameraLocation) | |
| if not cameraX or not cameraY then | |
| return nil | |
| end | |
| local character = safe_find_first("PlayerCharacterBP_C") or safe_find_first("Character") | |
| local targetX, targetY = read_actor_location_xy(character) | |
| if not targetX or not targetY then | |
| return nil | |
| end | |
| local dx = targetX - cameraX | |
| local dy = targetY - cameraY | |
| if math.abs(dx) < 0.001 and math.abs(dy) < 0.001 then | |
| return nil | |
| end | |
| return math.deg(math.atan(dy, dx)) | |
| end | |
| local function read_camera_cache_yaw(manager) | |
| local cache = safe_get_property(manager, "CameraCachePrivate") | |
| local pov = safe_get_property(cache, "POV") | |
| local rotation = safe_get_property(pov, "Rotation") | |
| return read_yaw_from_rotator(rotation) | |
| end | |
| local function read_actor_yaw(object, source) | |
| if not is_valid(object) then | |
| return nil, nil | |
| end | |
| local okRot, rot = pcall(function() | |
| return object:GetActorRotation() | |
| end) | |
| local yaw = okRot and read_yaw_from_rotator(rot) or nil | |
| if yaw then | |
| return yaw, source .. ":actorRotation" | |
| end | |
| local okForward, forward = pcall(function() | |
| return object:GetActorForwardVector() | |
| end) | |
| yaw = okForward and read_yaw_from_vector(forward) or nil | |
| if yaw then | |
| return yaw, source .. ":actorForward" | |
| end | |
| return nil, nil | |
| end | |
| local function read_yaw() | |
| local controller = get_controller() | |
| if controller then | |
| local okManager, manager = pcall(function() | |
| return controller.PlayerCameraManager | |
| end) | |
| if okManager and is_valid(manager) then | |
| local cacheYaw = read_camera_cache_yaw(manager) | |
| if cacheYaw then | |
| return cacheYaw, "cameraCache" | |
| end | |
| local okRot, rot = pcall(function() | |
| return manager:GetCameraRotation() | |
| end) | |
| local yaw = okRot and read_yaw_from_rotator(rot) or nil | |
| if yaw then | |
| return yaw, "camera" | |
| end | |
| local lookYaw = read_camera_look_yaw(manager) | |
| if lookYaw then | |
| return lookYaw, "cameraLook" | |
| end | |
| local managerYaw, managerSource = read_actor_yaw(manager, "cameraManager") | |
| if managerYaw then | |
| return managerYaw, managerSource | |
| end | |
| end | |
| end | |
| if controller then | |
| local okControl, controlRot = pcall(function() | |
| return controller:GetControlRotation() | |
| end) | |
| local controlYaw = okControl and read_yaw_from_rotator(controlRot) or nil | |
| if controlYaw and math.abs(controlYaw) > 0.001 then | |
| return controlYaw, "control" | |
| end | |
| local controllerYaw, controllerSource = read_actor_yaw(controller, "controller") | |
| if controllerYaw and math.abs(controllerYaw) > 0.001 then | |
| return controllerYaw, controllerSource | |
| end | |
| end | |
| return nil, "none" | |
| end | |
| local function yaw_to_index(yaw) | |
| local normalized = yaw % 360 | |
| if normalized < 0 then | |
| normalized = normalized + 360 | |
| end | |
| return math.floor((normalized / 22.5) + 0.5) % 16 | |
| end | |
| local function compass_text(yaw) | |
| local center = yaw_to_index(yaw) | |
| local parts = {} | |
| for offset = -2, 2 do | |
| local index = ((center + offset) % 16) + 1 | |
| local label = labels[index] | |
| parts[#parts + 1] = label | |
| end | |
| return table.concat(parts, " ") | |
| end | |
| local function cardinal_center(yaw) | |
| local normalized = yaw % 360 | |
| if normalized < 0 then | |
| normalized = normalized + 360 | |
| end | |
| local cardinals = { | |
| { label = "N", yaw = 0 }, | |
| { label = "E", yaw = 90 }, | |
| { label = "S", yaw = 180 }, | |
| { label = "W", yaw = 270 }, | |
| } | |
| local index = math.floor((normalized / 90) + 0.5) % 4 + 1 | |
| return index, cardinals | |
| end | |
| local function yaw_delta(a, b) | |
| local delta = (a - b + 180) % 360 - 180 | |
| return delta | |
| end | |
| local function find_text_class() | |
| local candidates = { | |
| "Class /Script/UMG.TextBlock", | |
| "/Script/UMG.TextBlock", | |
| } | |
| for _, path in ipairs(candidates) do | |
| local object = safe_static_find(path) | |
| if object then | |
| log("TextBlock class found: " .. full_name(object)) | |
| return object | |
| end | |
| end | |
| log("TextBlock class not found") | |
| return nil | |
| end | |
| local function find_text_library() | |
| local candidates = { | |
| "KismetTextLibrary /Script/Engine.Default__KismetTextLibrary", | |
| "/Script/Engine.Default__KismetTextLibrary", | |
| "Default__KismetTextLibrary", | |
| } | |
| for _, path in ipairs(candidates) do | |
| local object = safe_static_find(path) | |
| if object then | |
| log("KismetTextLibrary found: " .. full_name(object)) | |
| return object | |
| end | |
| end | |
| local object = safe_find_first("KismetTextLibrary") | |
| if object then | |
| log("KismetTextLibrary found by class: " .. full_name(object)) | |
| return object | |
| end | |
| log("KismetTextLibrary not found") | |
| return nil | |
| end | |
| local textLibrary = nil | |
| local function to_ftext(text) | |
| if not textLibrary or not is_valid(textLibrary) then | |
| textLibrary = find_text_library() | |
| end | |
| if textLibrary then | |
| local ok, value = pcall(function() | |
| return textLibrary:Conv_StringToText(text) | |
| end) | |
| if ok and value then | |
| return value | |
| end | |
| log("Conv_StringToText failed: " .. tostring(value)) | |
| end | |
| if type(FText) == "function" then | |
| local ok, value = pcall(function() | |
| return FText(text) | |
| end) | |
| if ok and value then | |
| return value | |
| end | |
| end | |
| return nil | |
| end | |
| local function set_text(widget, text) | |
| if not is_valid(widget) then | |
| return false | |
| end | |
| local ftext = to_ftext(text) | |
| if not ftext then | |
| log("cannot convert text: " .. tostring(text)) | |
| return false | |
| end | |
| local ok, err = pcall(function() | |
| widget:SetText(ftext) | |
| end) | |
| if not ok then | |
| log("SetText failed: " .. tostring(err)) | |
| return false | |
| end | |
| return true | |
| end | |
| local function get_text_string(widget) | |
| if not is_valid(widget) then | |
| return nil | |
| end | |
| local okText, ftext = pcall(function() | |
| return widget:GetText() | |
| end) | |
| if okText and ftext then | |
| local okString, value = pcall(function() | |
| return tostring(ftext) | |
| end) | |
| if okString and value then | |
| return value | |
| end | |
| end | |
| return nil | |
| end | |
| local function looks_like_compass_text(text) | |
| if not text then | |
| return false | |
| end | |
| text = tostring(text) | |
| return text == "v" | |
| or text == "N" | |
| or text == "E" | |
| or text == "S" | |
| or text == "W" | |
| or string.find(text, "COMPASS", 1, true) ~= nil | |
| or string.find(text, "NW", 1, true) ~= nil | |
| or string.find(text, "NE", 1, true) ~= nil | |
| or string.find(text, "SW", 1, true) ~= nil | |
| or string.find(text, "SE", 1, true) ~= nil | |
| end | |
| local function find_player_canvas() | |
| local best = nil | |
| local bestScore = -10000 | |
| local candidates = {} | |
| for _, panel in ipairs(safe_find_all("CanvasPanel")) do | |
| if is_valid(panel) then | |
| local name = full_name(panel) | |
| local score = 0 | |
| local isLivePlayerUi = string.find(name, "/Engine/Transient", 1, true) | |
| and string.find(name, "GameEngine", 1, true) | |
| and string.find(name, "GothicGameInstance", 1, true) | |
| and string.find(name, "Player_UI", 1, true) | |
| and string.find(name, "CanvasPanel_793", 1, true) | |
| if string.find(name, "Player_UI", 1, true) then | |
| score = score + 500 | |
| end | |
| if string.find(name, "Player_Widget", 1, true) then | |
| score = score + 350 | |
| end | |
| if string.find(name, "CanvasPanel_793", 1, true) then | |
| score = score + 200 | |
| end | |
| if isLivePlayerUi then | |
| score = score + 1000 | |
| end | |
| if string.find(name, "MainMenu", 1, true) | |
| or string.find(name, "FrontEnd", 1, true) | |
| or string.find(name, "Dialogue", 1, true) | |
| or string.find(name, "Dialog", 1, true) | |
| or string.find(name, "PlayerSpeak", 1, true) | |
| or string.find(name, "BorderWarning", 1, true) | |
| or string.find(name, "Transition", 1, true) | |
| or string.find(name, "Loot", 1, true) | |
| or string.find(name, "Inventory", 1, true) then | |
| score = score - 1000 | |
| end | |
| if string.find(name, "/Game/UI/Player/Player_Widget", 1, true) then | |
| score = score - 2000 | |
| end | |
| if string.find(name, "/Engine/Transient.Player_UI:", 1, true) then | |
| score = score - 2000 | |
| end | |
| candidates[#candidates + 1] = { object = panel, name = name, score = score } | |
| if score > bestScore then | |
| best = panel | |
| bestScore = score | |
| end | |
| end | |
| end | |
| table.sort(candidates, function(a, b) | |
| return a.score > b.score | |
| end) | |
| for index = 1, math.min(6, #candidates) do | |
| log("canvas candidate " .. tostring(index) .. " score=" .. tostring(candidates[index].score) .. ": " .. candidates[index].name) | |
| end | |
| if best and bestScore > 1000 then | |
| log("canvas selected score=" .. tostring(bestScore) .. ": " .. full_name(best)) | |
| return best | |
| end | |
| log("player canvas not found") | |
| return nil | |
| end | |
| local function construct_text_block(parent, text, x, y, fontSize) | |
| local textClass = find_text_class() | |
| if not textClass then | |
| return nil | |
| end | |
| local outer = parent | |
| local okOuter, parentOuter = pcall(function() | |
| return parent:GetOuter() | |
| end) | |
| if okOuter and is_valid(parentOuter) then | |
| outer = parentOuter | |
| end | |
| local ok, widget = pcall(function() | |
| return StaticConstructObject(textClass, outer) | |
| end) | |
| if not ok or not is_valid(widget) then | |
| log("StaticConstructObject TextBlock failed: " .. tostring(widget)) | |
| return nil | |
| end | |
| log("TextBlock constructed: " .. full_name(widget) .. "; outer=" .. full_name(outer)) | |
| if not set_text(widget, text) then | |
| return nil | |
| end | |
| pcall(function() | |
| widget:SetToolTipText(to_ftext(CONFIG.tag)) | |
| end) | |
| pcall(function() | |
| widget:SetVisibility(2) | |
| end) | |
| pcall(function() | |
| local font = widget.Font | |
| if font then | |
| font.Size = fontSize | |
| widget:SetFont(font) | |
| end | |
| end) | |
| pcall(function() | |
| widget:SetColorAndOpacity({ SpecifiedColor = CONFIG.textColor, ColorUseRule = 0 }) | |
| end) | |
| pcall(function() | |
| widget:SetRenderOpacity(1.0) | |
| end) | |
| pcall(function() | |
| widget:SetJustification(1) | |
| end) | |
| local okAdd, slot = pcall(function() | |
| return parent:AddChildToCanvas(widget) | |
| end) | |
| if not okAdd or not is_valid(slot) then | |
| log("AddChildToCanvas failed: " .. tostring(slot)) | |
| return nil | |
| end | |
| local positionX = x | |
| if CONFIG.useCenterAnchor then | |
| local okAnchor = pcall(function() | |
| slot:SetAnchors(anchors(0.5, 0.0, 0.5, 0.0)) | |
| end) | |
| state.centerAnchorSupported = okAnchor == true | |
| if not okAnchor then | |
| positionX = get_compass_center_x() + x | |
| log("center anchor failed; using absolute x=" .. tostring(positionX)) | |
| end | |
| end | |
| pcall(function() | |
| slot:SetAutoSize(true) | |
| end) | |
| pcall(function() | |
| slot:SetAlignment(vec2(0.5, 0.0)) | |
| end) | |
| pcall(function() | |
| slot:SetPosition(vec2(positionX, y)) | |
| end) | |
| pcall(function() | |
| slot:SetSize(vec2(CONFIG.width, CONFIG.height)) | |
| end) | |
| pcall(function() | |
| slot:SetZOrder(9999) | |
| end) | |
| state.createdWidgets[#state.createdWidgets + 1] = widget | |
| log("TextBlock added at x=" .. tostring(positionX) .. " y=" .. tostring(y) .. "; centerAnchor=" .. tostring(state.centerAnchorSupported)) | |
| return widget, slot | |
| end | |
| local function set_compass_visibility(visible) | |
| local visibility = visible and 0 or 2 | |
| if is_valid(state.marker) then | |
| pcall(function() | |
| state.marker:SetVisibility(visibility) | |
| end) | |
| end | |
| for _, widget in ipairs(state.labelWidgets) do | |
| if is_valid(widget) then | |
| pcall(function() | |
| widget:SetVisibility(visibility) | |
| end) | |
| end | |
| end | |
| end | |
| local function remove_widget(widget) | |
| if is_valid(widget) then | |
| pcall(function() | |
| widget:SetText(to_ftext("")) | |
| end) | |
| pcall(function() | |
| widget:SetRenderOpacity(0.0) | |
| end) | |
| pcall(function() | |
| widget:SetVisibility(2) | |
| end) | |
| pcall(function() | |
| widget:RemoveFromParent() | |
| end) | |
| end | |
| end | |
| local function destroy_compass() | |
| for _, widget in ipairs(state.createdWidgets) do | |
| remove_widget(widget) | |
| end | |
| state.createdWidgets = {} | |
| remove_widget(state.marker) | |
| for _, widget in ipairs(state.labelWidgets) do | |
| remove_widget(widget) | |
| end | |
| state.marker = nil | |
| state.markerSlot = nil | |
| state.labelWidgets = {} | |
| state.labelSlots = {} | |
| state.ready = false | |
| state.lastText = nil | |
| state.parentName = nil | |
| state.mainMapContext = nil | |
| state.centerX = nil | |
| state.centerAnchorSupported = nil | |
| end | |
| local function forget_compass(reason) | |
| state.createdWidgets = {} | |
| state.marker = nil | |
| state.markerSlot = nil | |
| state.labelWidgets = {} | |
| state.labelSlots = {} | |
| state.ready = false | |
| state.lastText = nil | |
| state.parentName = nil | |
| state.mainMapContext = nil | |
| state.centerX = nil | |
| state.centerAnchorSupported = nil | |
| state.creating = false | |
| log("compass references cleared: " .. tostring(reason)) | |
| end | |
| local function create_compass() | |
| if state.creating then | |
| return false | |
| end | |
| if state.ready and is_valid(state.marker) then | |
| local labelsReady = true | |
| for index = 1, 3 do | |
| labelsReady = labelsReady and is_valid(state.labelWidgets[index]) | |
| end | |
| if labelsReady then | |
| return true | |
| end | |
| end | |
| local currentMs = now_ms() | |
| if currentMs < state.nextCreateMs then | |
| return false | |
| end | |
| state.nextCreateMs = currentMs + CONFIG.retryDelayMs | |
| local ready, context = in_main_map() | |
| if not ready then | |
| log("create delayed: not MainMap; " .. tostring(context)) | |
| return false | |
| end | |
| local parent = find_player_canvas() | |
| if not parent then | |
| return false | |
| end | |
| state.creating = true | |
| destroy_compass() | |
| state.parentName = full_name(parent) | |
| state.centerX = 0 | |
| state.centerAnchorSupported = nil | |
| log("creating native TextBlock compass") | |
| local marker, markerSlot = construct_text_block(parent, "v", state.centerX, CONFIG.markerY, CONFIG.markerFontSize) | |
| state.marker = marker | |
| state.markerSlot = markerSlot | |
| state.labelWidgets = {} | |
| state.labelSlots = {} | |
| local fixedLabels = { "W", "N", "E" } | |
| for index = 1, 3 do | |
| local offset = (index - 2) * CONFIG.labelSpacing | |
| local widget, slot = construct_text_block(parent, fixedLabels[index], offset, CONFIG.labelY, CONFIG.labelFontSize) | |
| state.labelWidgets[index] = widget | |
| state.labelSlots[index] = slot | |
| end | |
| state.ready = is_valid(state.marker) | |
| for index = 1, 3 do | |
| state.ready = state.ready and is_valid(state.labelWidgets[index]) | |
| end | |
| if state.ready then | |
| set_compass_visibility(true) | |
| end | |
| state.creating = false | |
| log("compass ready=" .. tostring(state.ready)) | |
| return state.ready | |
| end | |
| local function update_compass() | |
| local currentMs = now_ms() | |
| if currentMs < state.suspendedUntilMs then | |
| return | |
| end | |
| local mainMapReady, mainMapContext = in_main_map() | |
| if not mainMapReady then | |
| if state.ready or state.mainMapContext ~= mainMapContext then | |
| forget_compass("not MainMap; " .. tostring(mainMapContext)) | |
| state.mainMapContext = mainMapContext | |
| end | |
| state.nextCreateMs = currentMs + CONFIG.retryDelayMs | |
| if currentMs - state.lastLogMs > 1000 then | |
| state.lastLogMs = currentMs | |
| log("state suspended: not MainMap; " .. tostring(mainMapContext)) | |
| end | |
| return | |
| end | |
| state.mainMapContext = mainMapContext | |
| local yaw, source = read_yaw() | |
| if yaw then | |
| state.lastYaw = yaw | |
| state.lastYawSource = source | |
| else | |
| yaw = state.lastYaw | |
| source = state.lastYawSource .. ":cached" | |
| end | |
| if currentMs - state.lastLogMs > 1000 then | |
| state.lastLogMs = currentMs | |
| log("state yaw=" .. tostring(yaw) .. "; source=" .. tostring(source) .. "; controller=" .. full_name(get_controller())) | |
| end | |
| if not create_compass() then | |
| return | |
| end | |
| if not yaw then | |
| return | |
| end | |
| local normalized = (yaw + CONFIG.yawOffsetDegrees) % 360 | |
| if normalized < 0 then | |
| normalized = normalized + 360 | |
| end | |
| local centerIndex, cardinals = cardinal_center(normalized) | |
| local center = cardinals[centerIndex] | |
| local left = cardinals[((centerIndex - 2) % 4) + 1] | |
| local right = cardinals[(centerIndex % 4) + 1] | |
| local currentLabels = { left.label, center.label, right.label } | |
| local labelsKey = table.concat(currentLabels, "|") | |
| if labelsKey ~= state.lastText then | |
| state.lastText = labelsKey | |
| for index = 1, 3 do | |
| set_text(state.labelWidgets[index], currentLabels[index]) | |
| end | |
| end | |
| local delta = yaw_delta(normalized, center.yaw) | |
| local centerX = 0 | |
| if state.centerAnchorSupported == false then | |
| centerX = get_compass_center_x() | |
| end | |
| local markerX = centerX + (delta / 90) * CONFIG.markerRange | |
| if is_valid(state.markerSlot) then | |
| pcall(function() | |
| state.markerSlot:SetPosition(vec2(markerX, CONFIG.markerY)) | |
| end) | |
| end | |
| if currentMs - state.lastMoveLogMs > 1000 then | |
| state.lastMoveLogMs = currentMs | |
| log("marker moved yaw=" .. tostring(yaw) .. "; displayYaw=" .. tostring(normalized) .. "; center=" .. center.label .. "; x=" .. tostring(markerX)) | |
| end | |
| end | |
| local loop | |
| loop = function(serial) | |
| if serial ~= state.loopSerial then | |
| return | |
| end | |
| local ok, err = pcall(update_compass) | |
| if not ok then | |
| log("update error: " .. tostring(err)) | |
| end | |
| if ExecuteWithDelay then | |
| ExecuteWithDelay(CONFIG.updateDelayMs, function() | |
| loop(serial) | |
| end) | |
| elseif ExecuteInGameThreadWithDelay then | |
| ExecuteInGameThreadWithDelay(CONFIG.updateDelayMs, function() | |
| loop(serial) | |
| end) | |
| end | |
| end | |
| local function tick_update() | |
| local ok, err = pcall(update_compass) | |
| if not ok then | |
| log("tick update error: " .. tostring(err)) | |
| end | |
| end | |
| local function start_loop(delay) | |
| if state.loopStarted then | |
| return | |
| end | |
| state.loopStarted = true | |
| state.loopSerial = state.loopSerial + 1 | |
| local serial = state.loopSerial | |
| if ExecuteWithDelay then | |
| ExecuteWithDelay(delay, function() | |
| loop(serial) | |
| end) | |
| elseif ExecuteInGameThreadWithDelay then | |
| ExecuteInGameThreadWithDelay(delay, function() | |
| loop(serial) | |
| end) | |
| else | |
| loop(serial) | |
| end | |
| end | |
| local function restart_loop(delay) | |
| state.loopStarted = false | |
| start_loop(delay) | |
| end | |
| reset_log() | |
| log("loaded version=" .. VERSION) | |
| if RegisterHook then | |
| pcall(function() | |
| if CONFIG.usePlayerTickHook then | |
| RegisterHook("/Script/Engine.PlayerController:PlayerTick", function() | |
| tick_update() | |
| end, nil) | |
| state.tickHooked = true | |
| log("PlayerTick hook registered") | |
| else | |
| log("PlayerTick hook disabled; using delayed loop") | |
| end | |
| end) | |
| pcall(function() | |
| if CONFIG.useClientRestartHook then | |
| RegisterHook("/Script/Engine.PlayerController:ClientRestart", nil, function() | |
| log("ClientRestart hook") | |
| forget_compass("ClientRestart") | |
| local currentMs = now_ms() | |
| state.suspendedUntilMs = currentMs + CONFIG.postLoadDelayMs | |
| state.nextCreateMs = state.suspendedUntilMs | |
| if not state.tickHooked then | |
| restart_loop(1000) | |
| end | |
| end) | |
| else | |
| log("ClientRestart hook disabled") | |
| end | |
| end) | |
| end | |
| if RegisterLoadMapPostHook then | |
| pcall(function() | |
| RegisterLoadMapPostHook(function() | |
| log("LoadMapPostHook") | |
| local currentMs = now_ms() | |
| state.suspendedUntilMs = currentMs + CONFIG.postLoadDelayMs | |
| state.nextCreateMs = state.suspendedUntilMs | |
| end) | |
| end) | |
| end | |
| if RegisterLoadMapPreHook then | |
| pcall(function() | |
| RegisterLoadMapPreHook(function() | |
| log("LoadMapPreHook") | |
| forget_compass("LoadMapPreHook") | |
| local currentMs = now_ms() | |
| state.suspendedUntilMs = currentMs + CONFIG.postLoadDelayMs | |
| state.nextCreateMs = state.suspendedUntilMs | |
| end) | |
| end) | |
| end | |
| start_loop(CONFIG.startupDelayMs) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment