Files
fs25-adsmartpickup/FS25_ADSmartPickup/adStatusHud.lua
T
masterdraco 979b7f72d5 feat(kæde): advarsel når en fabrik gården er afhængig af slukkes
Slukker spilleren en produktion, hvis vare et tændt flow kører (manuel vare
eller målet tager den i auto), en stald æder eller en aktiv kæde bruger,
kommer én besked pr. slukning med brugere og lagerdage, og en rød post
'Slukket men nødvendig' i Kræver handling og statusvinduet så længe den er
slukket og nødvendig. Ingen advarsel når en anden tændt produktion dækker,
lageret rækker over 60 dage, kæden selv slukkede den eller den var slukket
ved indlæsning. Tjekket kører i kædens tick også uden kæder; ren logik i
adDependency.lua.
2026-09-30 15:08:28 +02:00

452 lines
20 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 venstre Alt + S). Ø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)
if runLevel == "error" then level = "error" 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
-- 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 = farmId == nil or (vehicle.getOwnerFarmId ~= nil and vehicle:getOwnerFarmId() == farmId)
local held = (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