-- AD Status Hud -- Statusvinduet på skærmen (i stil med Simple Inspector): slås til/fra med input-handlingen SPU_TOGGLE_STATUS -- (standard venstre Alt + Insert). Øjebliksbilledet bygges af modens egne statusfunktioner (samme som fanerne) højst -- hvert sekund i update() og kun mens vinduet er tændt; draw() tegner kun de cachede linjer og bredder. -- Synligheden gemmes pr. bruger i modSettings/FS25_ADSmartPickup/status.xml (standard skjult). -- Kører kun på en klient (ikke dedikeret server). Alle spilkald er beskyttet; fejl logges én gang pr. nøgle. ADStatusHud = {} ADStatusHud.LOG_PREFIX = "ADSmartPickup: status: " ADStatusHud.REFRESH_MS = 1000 ADStatusHud.STUCK_WARN_MS = 120000 -- stået stille så længe (uden at Smart Pickup holder den) = advarsel ADStatusHud.TEXT_SIZE_PX = 14 ADStatusHud.MARGIN_PX = 8 ADStatusHud.SCREEN_OFFSET_PX = 16 -- afstand til skærmens højre kant ADStatusHud.TOP_Y = 1 - 0.12 -- øverste kant (normaliseret); under spillets info-boks ADStatusHud.SETTINGS_DIR = "modSettings/FS25_ADSmartPickup/" ADStatusHud.SETTINGS_FILE = "status.xml" ADStatusHud.SETTINGS_ROOT = "adSmartPickupStatus" ADStatusHud.HEADER_COLOR = {1, 1, 1, 1} ADStatusHud.visible = false ADStatusHud.lines = {} -- {{text, level, header, color, width}} ADStatusHud.layout = nil -- {x, y, width, height, textSize, marginX, marginY, padding} local timerMs = 0 local settingsLoaded = false local overlays = nil local loggedErrors = {} local registerToggleActionRef -- sat nederst (tastens handler til krogen) -- ---- hjælpere ---- local function logOnce(key, err) if loggedErrors[key] then return end loggedErrors[key] = true if Logging ~= nil and Logging.warning ~= nil then Logging.warning(ADStatusHud.LOG_PREFIX .. "%s fejlede: %s", tostring(key), tostring(err)) end end local function guarded(key, fn, ...) local ok, result = pcall(fn, ...) if not ok then logOnce(key, result) return nil end return result end local function textOf(key) if g_i18n == nil then return key end local ok, text = pcall(g_i18n.getText, g_i18n, key) return ok and text or key end function ADStatusHud.isClient() local mission = g_currentMission if mission == nil or mission.getIsClient == nil then return false end local ok, isClient = pcall(mission.getIsClient, mission) return ok and isClient == true end -- Fanernes egne tekst-/statusfunktioner ligger som metoder på SmartPickupFrame og bruger kun self.i18n: -- en let "frame" med spillets i18n genbruger dem uden at røre menuen. local function frameProxy() if SmartPickupFrame == nil then return nil end return setmetatable({i18n = g_i18n, lastStartFailed = {}}, {__index = SmartPickupFrame}) end local function fillTitle(fillType) if fillType == nil or g_fillTypeManager == nil then return "?" end local title = g_fillTypeManager.getFillTypeTitleByIndex ~= nil and g_fillTypeManager:getFillTypeTitleByIndex(fillType) or g_fillTypeManager:getFillTypeNameByIndex(fillType) return tostring(title or "?") end -- ---- øjebliksbillede ---- local WAGON_ACTIONS = { fetching = {key = "spu_status_act_fetchWagon", level = "waiting"}, waiting = {key = "spu_status_act_waitWagon", level = "waiting"}, emptying = {key = "spu_status_act_emptying", level = "running"}, homing = {key = "spu_status_act_homing", level = "running"}, } -- Traktorens handling som kort tekst + niveau (ADRunsController.getVehicleActivity som Flows-fanen). local function vehicleAction(adEnv, vehicle) local wagonState = ADFlowWagons ~= nil and ADFlowWagons.statusOf ~= nil and ADFlowWagons.statusOf(vehicle) or nil if wagonState ~= nil and WAGON_ACTIONS[wagonState] ~= nil then return textOf(WAGON_ACTIONS[wagonState].key), WAGON_ACTIONS[wagonState].level end local activity = ADRunsController.getVehicleActivity(adEnv, vehicle) local place = tostring(activity.place or "?") if activity.kind == "stopped" then return textOf("spu_activity_stopped"), "stopped" elseif activity.kind == "waiting" then return string.format(textOf("spu_status_act_waiting"), place), "waiting" elseif activity.kind == "holding" then return string.format(textOf("spu_status_act_holding"), place), "waiting" elseif activity.kind == "empty" and activity.fillType ~= nil then return string.format(textOf("spu_status_act_fetch"), fillTitle(activity.fillType), place), "running" end return string.format(textOf("spu_status_act_driving"), place), "running" end -- Fyld i % af hele vogntoget (AutoDrives egne opgørelser, som kildevalget bruger); nil uden kapacitet. local function fillPercent(adEnv, vehicle) local autoDrive = adEnv.AutoDrive if autoDrive == nil or autoDrive.getAllUnits == nil or autoDrive.getAllFillLevels == nil then return nil end local level, capacity = autoDrive.getAllFillLevels(autoDrive.getAllUnits(vehicle)) if type(level) ~= "number" or type(capacity) ~= "number" or capacity <= 0 then return nil end return math.max(0, math.min(100, level / capacity * 100)) end local function farmVehiclesById(adEnv) local byId = {} for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do local id = ADRunsController.getVehicleId(vehicle) if id ~= nil then byId[id] = vehicle end end return byId end -- Én linje pr. traktor i et kørende flow (flows der kan stoppes: startet, eller holder vogn/henter). function ADStatusHud.flowEntries(adEnv, proxy) local entries = {} if ADRunsController == nil or ADRunsController.store == nil or ADFlowView == nil then return entries end local byId = farmVehiclesById(adEnv) for _, run in ipairs(ADFlowView.orderedRuns(ADRunsController.store.runs or {})) do local status = ADRunsController.getRunStatus(adEnv, run) if status.canStop then local runLevel = proxy ~= nil and proxy.flowLevel ~= nil and guarded("flowLevel", proxy.flowLevel, proxy, adEnv, run) or nil for _, vehicleId in ipairs(run.vehicleIds or {}) do local vehicle = byId[vehicleId] if vehicle ~= nil then local action, level = vehicleAction(adEnv, vehicle) -- farven følger flowets niveau som Flows-listen (venter/fejl), ellers traktorens egen handling if runLevel == "error" or runLevel == "waiting" then level = runLevel end table.insert(entries, {vehicle = tostring(ADRuns.vehicleLabel(vehicle)), action = action, fillPercent = guarded("fillPercent", fillPercent, adEnv, vehicle), level = level}) end end end end return entries end -- Én linje pr. mark med aktivt markarbejde (samme statustekst og farve som Marker-fanen). function ADStatusHud.fieldEntries(proxy) local entries = {} if ADFields == nil or ADFieldJobs == nil or ADFieldModel == nil or proxy == nil then return entries end for _, info in ipairs(ADFields.list()) do local field = ADFieldJobs.getField(info.id) if field ~= nil and (ADFieldModel.isWorking(field) or ADFieldJobs.awaitingPlayer(info.id) ~= nil) then local names = {} for _, assignment in ipairs(ADFieldJobs.assignmentsFor(info.id)) do table.insert(names, ADRuns.vehicleLabel(assignment.vehicle)) end table.insert(entries, { fieldId = info.id, work = proxy:fieldStatusText(field, info, true), vehicle = #names > 0 and ADFlowView.joinCounted(names, ", ", nil) or nil, progress = SmartPickupFrame.monthCpProgress ~= nil and guarded("fieldProgress", SmartPickupFrame.monthCpProgress, info.id) or nil, level = proxy:fieldLevel(info.id), }) end end return entries end -- Kæder med manglende led + ventende forslag + slukkede nødvendige fabrikker (samme punkter og tekster som -- "Kræver handling"; de to sidste står under Advarsler). function ADStatusHud.chainEntries(proxy) local chains, suggestions = {}, {} local result = {chains = chains, suggestions = suggestions, dependencies = {}} if ADChain == nil or ADChain.last == nil or ADStorageStats == nil or ADStorageStats.chainEntries == nil or proxy == nil then return result end for _, entry in ipairs(ADStorageStats.chainEntries(ADChain.last)) do if entry.kind == "chainProblem" then table.insert(chains, {text = proxy:alertText(entry), level = "error"}) elseif entry.kind == "chainSuggest" then table.insert(suggestions, {text = proxy:alertText(entry), level = "waiting"}) elseif entry.kind == "dependencyOff" then table.insert(result.dependencies, {text = proxy:alertText(entry), level = "error"}) end end return result end -- Solgt/slettet køretøj (ADStuckDiag kan holde det et øjeblik efter salget). local function isGone(vehicle) if vehicle == nil or vehicle.isDeleted then return true end if vehicle.rootNode == nil then return true end return entityExists ~= nil and not entityExists(vehicle.rootNode) end -- Gårdens køretøjer der har stået stille over grænsen, uden at Smart Pickup selv holder dem (Wait/udkørsel). function ADStatusHud.stuckEntries() local entries = {} if ADStuckDiag == nil or ADStuckDiag.stillVehicles == nil then return entries end local farmId = g_currentMission ~= nil and g_currentMission.getFarmId ~= nil and g_currentMission:getFarmId() or nil for _, still in ipairs(ADStuckDiag.stillVehicles(ADStatusHud.STUCK_WARN_MS)) do local vehicle = still.vehicle local owned = not isGone(vehicle) and (farmId == nil or (vehicle.getOwnerFarmId ~= nil and vehicle:getOwnerFarmId() == farmId)) local held = owned and ((ADUnloadWait ~= nil and ADUnloadWait.getWaitFor ~= nil and ADUnloadWait.getWaitFor(vehicle) ~= nil) or (ADOutbound ~= nil and ADOutbound.getHoldFor ~= nil and ADOutbound.getHoldFor(vehicle) ~= nil)) if owned and not held then table.insert(entries, {text = string.format(textOf("spu_status_stuck"), tostring(ADRuns.vehicleLabel(vehicle)), math.floor(still.ms / 60000)), level = "error"}) end end return entries end function ADStatusHud.snapshot(adEnv) local proxy = frameProxy() local snapshot = {flows = {}, fields = {}, chains = {}, warnings = {}} if adEnv ~= nil then snapshot.flows = guarded("flows", ADStatusHud.flowEntries, adEnv, proxy) or {} end snapshot.fields = guarded("fields", ADStatusHud.fieldEntries, proxy) or {} local chainResult = guarded("chains", ADStatusHud.chainEntries, proxy) or {} snapshot.chains = chainResult.chains or {} for _, entry in ipairs(chainResult.dependencies or {}) do table.insert(snapshot.warnings, entry) end for _, entry in ipairs(guarded("stuck", ADStatusHud.stuckEntries) or {}) do table.insert(snapshot.warnings, entry) end for _, entry in ipairs(chainResult.suggestions or {}) do table.insert(snapshot.warnings, entry) end return snapshot end -- ---- layout og cache (kun i update) ---- local function gameInfoDisplay() local hud = g_currentMission ~= nil and g_currentMission.hud or nil return hud ~= nil and hud.gameInfoDisplay or nil end -- Størrelser i skærmkoordinater, skaleret som spillets egen HUD (Simple Inspector-mønster). local function measureUnits() local display = gameInfoDisplay() if display == nil or display.scalePixelToScreenHeight == nil or display.scalePixelToScreenVector == nil then return nil end local textSize = display:scalePixelToScreenHeight(ADStatusHud.TEXT_SIZE_PX) local marginX, marginY = display:scalePixelToScreenVector({ADStatusHud.MARGIN_PX, ADStatusHud.MARGIN_PX}) local offsetX = display:scalePixelToScreenVector({ADStatusHud.SCREEN_OFFSET_PX, 0}) return textSize, marginX, marginY, offsetX end -- Øverste kant: fast under info-boksen; ligger info-boksens position lavere, rykkes vinduet ned under den. local function topY(marginY) local top = ADStatusHud.TOP_Y local display = gameInfoDisplay() if display ~= nil and display.getPosition ~= nil then local ok, _, y = pcall(display.getPosition, display) if ok and type(y) == "number" and y > 0.5 and y < 1 then top = math.min(top, y - marginY) end end return top end function ADStatusHud.rebuild(adEnv) local lines = ADStatusView.lines(ADStatusHud.snapshot(adEnv), textOf, ADStatusView.MAX_LINES) local textSize, marginX, marginY, offsetX = measureUnits() if textSize == nil then ADStatusHud.lines, ADStatusHud.layout = {}, nil return end local maxWidth = 0 for _, line in ipairs(lines) do setTextBold(line.header == true) line.width = getTextWidth(textSize, line.text) line.color = line.header and ADStatusHud.HEADER_COLOR or (ADFlowView.LEVEL_COLORS[line.level] or ADStatusHud.HEADER_COLOR) if line.width > maxWidth then maxWidth = line.width end end setTextBold(false) local width = maxWidth + 2 * marginX local height = #lines * textSize + 2 * marginY local top = topY(marginY) ADStatusHud.lines = lines ADStatusHud.layout = {x = 1 - offsetX - width, y = top - height, top = top, width = width, height = height, textSize = textSize, marginX = marginX, marginY = marginY, padding = marginX * 0.5} end -- ---- overlays ---- local function createOverlays() if overlays ~= nil then return overlays end if g_overlayManager == nil or HUD == nil or HUD.COLOR == nil or HUD.COLOR.BACKGROUND == nil then return nil end local r, g, b, a = unpack(HUD.COLOR.BACKGROUND) local created = { middle = g_overlayManager:createOverlay("gui.gameInfo_middle", 0, 0, 0, 0), left = g_overlayManager:createOverlay("gui.gameInfo_left", 0, 0, 0, 0), right = g_overlayManager:createOverlay("gui.gameInfo_right", 0, 0, 0, 0), } for _, overlay in pairs(created) do overlay:setColor(r, g, b, a) end overlays = created return overlays end local function deleteOverlays() if overlays == nil then return end for _, overlay in pairs(overlays) do if overlay.delete ~= nil then pcall(overlay.delete, overlay) end end overlays = nil end -- ---- indstillinger (pr. bruger, ikke i savegamet) ---- local function settingsPath() if getUserProfileAppPath == nil then return nil end return getUserProfileAppPath() .. ADStatusHud.SETTINGS_DIR .. ADStatusHud.SETTINGS_FILE end function ADStatusHud.loadSettings() settingsLoaded = true local path = settingsPath() if path == nil or fileExists == nil or not fileExists(path) then return end local xmlFile = XMLFile.load("ADSmartPickupStatus", path) if xmlFile == nil then return end ADStatusHud.visible = xmlFile:getBool(ADStatusHud.SETTINGS_ROOT .. "#visible", false) == true xmlFile:delete() end function ADStatusHud.saveSettings() local path = settingsPath() if path == nil then return end if createFolder ~= nil then createFolder(getUserProfileAppPath() .. "modSettings/") createFolder(getUserProfileAppPath() .. ADStatusHud.SETTINGS_DIR) end local xmlFile = XMLFile.create("ADSmartPickupStatus", path, ADStatusHud.SETTINGS_ROOT) if xmlFile == nil then logOnce("saveSettings", "kunne ikke oprette " .. path) return end xmlFile:setBool(ADStatusHud.SETTINGS_ROOT .. "#visible", ADStatusHud.visible == true) xmlFile:save() xmlFile:delete() end -- ---- offentlige kald fra moden ---- function ADStatusHud.toggle() ADStatusHud.visible = not ADStatusHud.visible timerMs = ADStatusHud.REFRESH_MS -- byg straks ved næste update if not ADStatusHud.visible then ADStatusHud.lines, ADStatusHud.layout = {}, nil end guarded("saveSettings", ADStatusHud.saveSettings) end -- Action-event-callback (target = ADStatusHud). function ADStatusHud.onToggleAction() ADStatusHud.toggle() end function ADStatusHud.update(dt, adEnv) if not ADStatusHud.isClient() then return end if not settingsLoaded then guarded("loadSettings", ADStatusHud.loadSettings) timerMs = ADStatusHud.REFRESH_MS end if not ADStatusHud.visible then return end timerMs = timerMs + (dt or 0) if timerMs < ADStatusHud.REFRESH_MS then return end timerMs = 0 guarded("rebuild", ADStatusHud.rebuild, adEnv) end -- Tegnes ikke ved åben menu/dialog, skjult HUD, søvn eller åben chat. local function shouldSkipDraw() if g_gui ~= nil and g_gui:getIsGuiVisible() then return true end if g_noHudModeEnabled then return true end local mission = g_currentMission local hud = mission ~= nil and mission.hud or nil if hud == nil or hud.isVisible == false then return true end if g_sleepManager ~= nil and g_sleepManager.getIsSleeping ~= nil and g_sleepManager:getIsSleeping() then return true end if hud.chatDisplay ~= nil and hud.chatDisplay.getVisible ~= nil and hud.chatDisplay:getVisible() then return true end return false end local function render(layout, bg) bg.middle:setDimension(layout.width, layout.height) bg.middle:setPosition(layout.x, layout.y) bg.middle:render() bg.left:setDimension(layout.padding, layout.height) bg.left:setPosition(layout.x - layout.padding, layout.y) bg.left:render() bg.right:setDimension(layout.padding, layout.height) bg.right:setPosition(layout.x + layout.width, layout.y) bg.right:render() setTextAlignment(RenderText.ALIGN_LEFT) setTextVerticalAlignment(RenderText.VERTICAL_ALIGN_TOP) local textX, textY = layout.x + layout.marginX, layout.top - layout.marginY for _, line in ipairs(ADStatusHud.lines) do local color = line.color setTextBold(line.header == true) setTextColor(color[1], color[2], color[3], color[4]) renderText(textX, textY, layout.textSize, line.text) textY = textY - layout.textSize end end function ADStatusHud.draw() if not ADStatusHud.visible or ADStatusHud.layout == nil or #ADStatusHud.lines == 0 then return end if shouldSkipDraw() then return end local bg = createOverlays() if bg == nil then return end local ok, err = pcall(render, ADStatusHud.layout, bg) -- tekststate nulstilles altid, også efter en fejl (andre mods/spillet tegner videre med den) setTextColor(1, 1, 1, 1) setTextAlignment(RenderText.ALIGN_LEFT) setTextVerticalAlignment(RenderText.VERTICAL_ALIGN_BASELINE) setTextBold(false) if not ok then logOnce("draw", err) end end function ADStatusHud.delete() deleteOverlays() if PlayerInputComponent ~= nil and PlayerInputComponent.spuStatusToggleHandler == registerToggleActionRef then PlayerInputComponent.spuStatusToggleHandler = nil end ADStatusHud.lines, ADStatusHud.layout = {}, nil settingsLoaded = false timerMs = 0 end -- ---- tast: global spiller-handling (til fods og i køretøjer) ---- -- PlayerInputComponent.registerGlobalPlayerActionEvents kaldes ved hvert kontekstskift (til fods / i køretøj); -- samme mønster som FS25_Courseplay (CP_OPEN_INGAME_MENU) og FS25_EasyDevControls. Hjælpeteksten skjules i F1. local function registerToggleAction(component) if not ADStatusHud.isClient() then return end local player = component ~= nil and component.player or nil if player ~= nil and player.isOwner == false then return end if g_inputBinding == nil or InputAction == nil or InputAction.SPU_TOGGLE_STATUS == nil then return end local _, eventId = g_inputBinding:registerActionEvent(InputAction.SPU_TOGGLE_STATUS, ADStatusHud, ADStatusHud.onToggleAction, false, true, false, true) if eventId ~= nil then g_inputBinding:setActionEventTextVisibility(eventId, false) end end registerToggleActionRef = function(component) guarded("registerAction", registerToggleAction, component) end -- Spillets klasse overlever en genindlæsning af savegamet, moden gør ikke: krogen lægges kun én gang (markør på -- klassen) og kalder den aktuelle indlæsnings handler, så en gammel ADStatusHud aldrig registrerer tasten igen. function ADStatusHud.installInputHook() if PlayerInputComponent == nil or PlayerInputComponent.registerGlobalPlayerActionEvents == nil or Utils == nil then return false end PlayerInputComponent.spuStatusToggleHandler = registerToggleActionRef if PlayerInputComponent.spuStatusToggleHooked then return true end PlayerInputComponent.registerGlobalPlayerActionEvents = Utils.appendedFunction(PlayerInputComponent.registerGlobalPlayerActionEvents, function(component, ...) local handler = PlayerInputComponent.spuStatusToggleHandler if handler ~= nil then handler(component) end end) PlayerInputComponent.spuStatusToggleHooked = true return true end if PlayerInputComponent ~= nil and Utils ~= nil then ADStatusHud.installInputHook() end