Files
masterdraco 1c60529dfd fix(status): numpad * som tast og log når vinduet ikke kan tegnes
Standardtasten skifter fra venstre Alt + Insert (kolliderer med
FS25_UniversalAutoload) til numpad *. Mangler info-boksens skalering eller
overlays, logges det én gang. Statusvinduet henter "start afvist" fra den
rigtige ESC-fane, så et flow hvis start fejlede vises rødt som i Flows-listen.
2026-09-30 15:28:56 +02:00

474 lines
22 KiB
Lua

-- AD Status Hud
-- Statusvinduet på skærmen (i stil med Simple Inspector): slås til/fra med input-handlingen SPU_TOGGLE_STATUS
-- (standard numpad *). Ø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. "Start afvist" (lastStartFailed, rødt flow)
-- huskes på den rigtige ESC-fane (ADSmartPickup.guiFrame): tabellen deles, så vinduet viser samme farve som
-- Flows-listen (flowLevel rydder en afvisning når flowet kører — det samme som fanen selv gør).
local function frameProxy()
if SmartPickupFrame == nil then return nil end
local real = ADSmartPickup ~= nil and ADSmartPickup.guiFrame or nil
local failed = {}
if type(real) == "table" then
if type(real.lastStartFailed) ~= "table" then real.lastStartFailed = {} end
failed = real.lastStartFailed
end
return setmetatable({i18n = g_i18n, lastStartFailed = failed}, {__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
-- spillets info-boks (skalering) findes ikke: vinduet kan ikke måles og tegnes
logOnce("measure", "gameInfoDisplay mangler eller kan ikke skalere")
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 ok0, bg = pcall(createOverlays)
if not ok0 or bg == nil then
-- uden baggrund tegnes intet (g_overlayManager/HUD.COLOR mangler eller createOverlay fejlede)
logOnce("overlays", ok0 and "g_overlayManager eller HUD.COLOR.BACKGROUND mangler" or bg)
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