diff --git a/CHANGELOG.md b/CHANGELOG.md index 78fc7dc..d211ddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ står i [docs/BRUGERMANUAL.md](docs/BRUGERMANUAL.md) (dansk) og [docs/USER_MANUA ## 1.61 — Produktionskæder -- **1.61.0** Produktionskæder: slå et produkt til i en fabrik, og moden holder det kørende helt tilbage til marken. Kæden regner baglæns gennem dine egne fabrikker og stalde til afgrøderne og ser 12 måneder frem. Ny fane **Kæder** med træ pr. rod-produktion, farvet status, fold ud/ind og **[Pause]**/**[Start]**. Marker markeret **Fri til kæden** skifter afgrøde automatisk (højst 2 i minuttet); andre marker får forslag med **[Godkend]**/**[Afvis]** (højst 3 pr. afgrøde) i fanen og som ét samlet punkt i **Kræver handling**. En afgrøde, du selv har valgt, ændres aldrig. Mellemled tændes og slukkes kun igen, hvis kæden tændte dem. Flows **Kæde: → ** oprettes med en ledig pool-traktor og tipvogn (ellers stoppet: *mangler traktor/vogn*; paller altid stoppet) og stoppes, ikke slettes, når kæden slås fra. Dine egne tændte produktioner bruges som kilde først; slukker du en produktion, tænder kæden den ikke igen, før du tænder en ny. Stoppede kædeflows slippes af kæden. Ingen handlinger de første 2 minutter, ingen markskift før alle marker er scannet, og intet slukkes ved delvise læsefejl. Begrænsninger: intet køb, ingen dyrekøb, flerårige afgrøder og græsmarker skiftes aldrig, grundudbytte uden gødningsbonus. **Vigtigt:** hver produktion, du tænder i en fabrik, prøver moden at forsyne — tænd kun det, du vil have produceret; sluk produktionen eller tryk [Pause] for at stoppe kæden. Manglende led forklares i klart sprog med råd (*Ingen af dine fabrikker laver Mel — byg en eller køb*, *Mølle kan kun lave 800 af 1.0k/md*, *Dine stalde leverer ikke nok Mælk …*, *… er slukket af dig*, *Kredsløb omkring …*): som rød tekst under varen i fanen Kæder, som én rød linje pr. kæde i **Kræver handling** (højst 2; klik åbner kæden foldet ud) og som engangsbesked med kædens navn foran, når et nyt led mangler (ikke i opstartsfristen eller mens markerne scannes). Manual og ordliste opdateret. +- **1.61.0** Produktionskæder: slå et produkt til i en fabrik, og moden holder det kørende helt tilbage til marken. Kæden regner baglæns gennem dine egne fabrikker og stalde til afgrøderne og ser 12 måneder frem. Ny fane **Kæder** med træ pr. rod-produktion, farvet status, fold ud/ind og **[Pause]**/**[Start]**. Marker markeret **Fri til kæden** skifter afgrøde automatisk (højst 2 i minuttet); andre marker får forslag med **[Godkend]**/**[Afvis]** (højst 3 pr. afgrøde) i fanen og som ét samlet punkt i **Kræver handling**. En afgrøde, du selv har valgt, ændres aldrig. Mellemled tændes og slukkes kun igen, hvis kæden tændte dem. Flows **Kæde: → ** oprettes med en ledig pool-traktor og tipvogn (ellers stoppet: *mangler traktor/vogn*; paller altid stoppet) og stoppes, ikke slettes, når kæden slås fra. Dine egne tændte produktioner bruges som kilde først; slukker du en produktion, tænder kæden den ikke igen, før du tænder en ny. Stoppede kædeflows slippes af kæden. Ingen handlinger de første 2 minutter, ingen markskift før alle marker er scannet, og intet slukkes ved delvise læsefejl. Begrænsninger: intet køb, ingen dyrekøb, flerårige afgrøder og græsmarker skiftes aldrig, grundudbytte uden gødningsbonus. **Vigtigt:** hver produktion, du tænder i en fabrik, prøver moden at forsyne — tænd kun det, du vil have produceret; sluk produktionen eller tryk [Pause] for at stoppe kæden. Manglende led forklares i klart sprog med råd (*Ingen af dine fabrikker laver Mel — byg en eller køb*, *Mølle kan kun lave 800 af 1.0k/md*, *Dine stalde leverer ikke nok Mælk …*, *… er slukket af dig*, *Kredsløb omkring …*): som rød tekst under varen i fanen Kæder, som én rød linje pr. kæde i **Kræver handling** (højst 2; klik åbner kæden foldet ud) og som engangsbesked med kædens navn foran, når et nyt led mangler (ikke i opstartsfristen eller mens markerne scannes). Nyt **statusvindue** på skærmen (i stil med Simple Inspector), som slås til/fra med **venstre Alt + S** (kan ændres under Styring; virker til fods og i køretøjer): øverst til højre under info-boksen vises flow-traktorernes handling og fyld, markarbejde i gang, kæder med manglende led og advarsler (køretøjer der har stået stille over 2 min, ventende kædeforslag) — højst 18 linjer, opdateres hvert sekund, skjules ved menu/dialog, skjult HUD og søvn; tændt/slukket gemmes pr. bruger i `modSettings/FS25_ADSmartPickup/status.xml`. Manual og ordliste opdateret. ## 1.60 — "Kræver handling" → flow, menuplacering, flow-vogne i guiden diff --git a/FS25_ADSmartPickup/adFlowWagons.lua b/FS25_ADSmartPickup/adFlowWagons.lua index ee3ac15..bbb57ca 100644 --- a/FS25_ADSmartPickup/adFlowWagons.lua +++ b/FS25_ADSmartPickup/adFlowWagons.lua @@ -562,6 +562,15 @@ function ADFlowWagons.isEmptying(vehicle) return emptying[vehicle] ~= nil end +-- Kun læsning (statusvinduet): hvad flowets vogn-logik har traktoren i gang med -> "fetching"|"waiting"|"emptying"|"homing"|nil +function ADFlowWagons.statusOf(vehicle) + if fetching[vehicle] ~= nil then return "fetching" end + if waiting[vehicle] ~= nil then return "waiting" end + if emptying[vehicle] ~= nil then return "emptying" end + if homing[vehicle] ~= nil then return "homing" end + return nil +end + function ADFlowWagons.sendHome(adEnv, run) for _, vehicleId in ipairs(run.vehicleIds or {}) do local vehicle = ADRunsController.findVehicleById(adEnv, vehicleId) diff --git a/FS25_ADSmartPickup/adSmartPickup.lua b/FS25_ADSmartPickup/adSmartPickup.lua index 8034482..72868ed 100644 --- a/FS25_ADSmartPickup/adSmartPickup.lua +++ b/FS25_ADSmartPickup/adSmartPickup.lua @@ -1335,6 +1335,16 @@ function ADSmartPickup:update(dt) Logging.warning(ADSmartPickup.LOG_PREFIX .. "kunne ikke gendanne varevalg: %s", tostring(err)) supplyState = setmetatable({}, {__mode = "k"}) end + if ADStatusHud ~= nil then pcall(ADStatusHud.update, dt, getAutoDriveEnv()) end +end + +-- Statusvinduet på skærmen (adStatusHud.lua): tegner kun den cachede tekst. +function ADSmartPickup:draw() + if ADStatusHud ~= nil then pcall(ADStatusHud.draw) end +end + +function ADSmartPickup:deleteMap() + if ADStatusHud ~= nil then pcall(ADStatusHud.delete) end end addModEventListener(ADSmartPickup) diff --git a/FS25_ADSmartPickup/adStatusHud.lua b/FS25_ADSmartPickup/adStatusHud.lua new file mode 100644 index 0000000..b93f8f1 --- /dev/null +++ b/FS25_ADSmartPickup/adStatusHud.lua @@ -0,0 +1,447 @@ +-- 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 (samme punkter og tekster som "Kræver handling"). +function ADStatusHud.chainEntries(proxy) + local chains, suggestions = {}, {} + local result = {chains = chains, suggestions = suggestions} + 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"}) + 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(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 diff --git a/FS25_ADSmartPickup/adStatusView.lua b/FS25_ADSmartPickup/adStatusView.lua new file mode 100644 index 0000000..1251cd6 --- /dev/null +++ b/FS25_ADSmartPickup/adStatusView.lua @@ -0,0 +1,143 @@ +-- AD Status View +-- Ren logik for statusvinduet på skærmen (slås til/fra med en tast, se adStatusHud.lua): øjebliksbilledet +-- -> færdige linjer {text, level, header}. Sektioner i fast rækkefølge Flows/Marker/Kæder/Advarsler, kun med +-- indhold; højst maxLines linjer (resten "+k mere"); hver tekst højst MAX_CHARS tegn (UTF-8-sikkert, ".."). +-- +-- snapshot = { +-- flows = {{vehicle, action, fillPercent (0-100|nil), level}}, +-- fields = {{fieldId, work, vehicle|nil, progress (0-1|nil), level}}, +-- chains = {{text, level}}, -- standard rød +-- warnings = {{text, level}}, -- standard gul +-- } +-- level = "running"|"waiting"|"error"|"stopped" (farver: ADFlowView.LEVEL_COLORS); overskrifter har level "header". + +ADStatusView = {} + +ADStatusView.MAX_LINES = 18 +ADStatusView.MAX_CHARS = 48 +ADStatusView.VEHICLE_CHARS = 18 -- traktornavnet må ikke æde hele linjen +ADStatusView.MIN_FLEX_CHARS = 6 +ADStatusView.SEPARATOR = " · " +ADStatusView.ELLIPSIS = ".." + +local LEVELS = {running = true, waiting = true, error = true, stopped = true} + +local SECTIONS = { + {key = "flows", header = "spu_status_flows", defaultLevel = "running"}, + {key = "fields", header = "spu_status_fields", defaultLevel = "running"}, + {key = "chains", header = "spu_status_chains", defaultLevel = "error"}, + {key = "warnings", header = "spu_status_warnings", defaultLevel = "waiting"}, +} + +-- ---- UTF-8-sikker længde/afkortning (æøå er flere bytes) ---- + +function ADStatusView.textLength(text) + local count = 0 + for _ in tostring(text):gmatch("[^\128-\191]") do count = count + 1 end + return count +end + +local function utf8Prefix(text, chars) + local result, count = {}, 0 + for char in text:gmatch("[^\128-\191][\128-\191]*") do + if count >= chars then break end + table.insert(result, char) + count = count + 1 + end + return table.concat(result) +end + +function ADStatusView.fit(text, chars) + text = tostring(text or "") + if ADStatusView.textLength(text) <= chars then return text end + local ellipsis = ADStatusView.ELLIPSIS + return utf8Prefix(text, math.max(0, chars - ADStatusView.textLength(ellipsis))) .. ellipsis +end + +-- Dele samlet med " · "; delen på flexIndex får den plads der er tilbage, så fx fyldprocenten bevares. +local function joinFitted(parts, flexIndex) + local sep = ADStatusView.SEPARATOR + local fixed = ADStatusView.textLength(sep) * (#parts - 1) + for index, part in ipairs(parts) do + if index ~= flexIndex then fixed = fixed + ADStatusView.textLength(part) end + end + local budget = math.max(ADStatusView.MIN_FLEX_CHARS, ADStatusView.MAX_CHARS - fixed) + local fitted = {} + for index, part in ipairs(parts) do + fitted[index] = index == flexIndex and ADStatusView.fit(part, budget) or part + end + return ADStatusView.fit(table.concat(fitted, sep), ADStatusView.MAX_CHARS) +end + +local function percentText(value) + return string.format("%d %%", math.floor(value + 0.5)) +end + +-- ---- linjer pr. sektion ---- + +local function flowText(entry) + local parts = {ADStatusView.fit(entry.vehicle or "?", ADStatusView.VEHICLE_CHARS), tostring(entry.action or "")} + if type(entry.fillPercent) == "number" then table.insert(parts, percentText(entry.fillPercent)) end + return joinFitted(parts, 2) +end + +local function fieldText(entry) + local parts = {string.format("F%s %s", tostring(entry.fieldId or "?"), tostring(entry.work or ""))} + if entry.vehicle ~= nil and entry.vehicle ~= "" then + table.insert(parts, ADStatusView.fit(entry.vehicle, ADStatusView.VEHICLE_CHARS)) + end + if type(entry.progress) == "number" then table.insert(parts, percentText(entry.progress * 100)) end + return joinFitted(parts, 1) +end + +local function plainText(entry) + return ADStatusView.fit(entry.text or "", ADStatusView.MAX_CHARS) +end + +local TEXT_OF_SECTION = {flows = flowText, fields = fieldText, chains = plainText, warnings = plainText} + +local function levelOf(entry, default) + return LEVELS[entry.level] and entry.level or default +end + +-- Alle linjer uden loft. +local function allLines(snapshot, textOf) + local lines = {} + for _, section in ipairs(SECTIONS) do + local entries = snapshot[section.key] or {} + if #entries > 0 then + table.insert(lines, {text = ADStatusView.fit(textOf(section.header), ADStatusView.MAX_CHARS), level = "header", header = true}) + for _, entry in ipairs(entries) do + table.insert(lines, {text = TEXT_OF_SECTION[section.key](entry), level = levelOf(entry, section.defaultLevel), header = false}) + end + end + end + return lines +end + +-- Afkort til maxLines: sidste plads bliver "+k mere" (k = skjulte indholdslinjer); en overskrift uden +-- indhold lige før afkortningen droppes. +local function capLines(lines, maxLines, textOf) + if #lines <= maxLines then return lines end + local kept = {} + for index = 1, math.max(0, maxLines - 1) do kept[index] = lines[index] end + while #kept > 0 and kept[#kept].header do table.remove(kept) end + local hidden = 0 + for index = #kept + 1, #lines do + if not lines[index].header then hidden = hidden + 1 end + end + local ok, text = pcall(string.format, textOf("spu_status_more"), hidden) + table.insert(kept, {text = ADStatusView.fit(ok and text or ("+" .. hidden), ADStatusView.MAX_CHARS), level = "stopped", header = false}) + return kept +end + +function ADStatusView.lines(snapshot, textOf, maxLines) + snapshot = snapshot or {} + textOf = textOf or function(key) return key end + maxLines = math.max(1, math.floor(maxLines or ADStatusView.MAX_LINES)) + local lines = allLines(snapshot, textOf) + if #lines == 0 then + return {{text = ADStatusView.fit(textOf("spu_status_idle"), ADStatusView.MAX_CHARS), level = "stopped", header = false}} + end + return capLines(lines, maxLines, textOf) +end diff --git a/FS25_ADSmartPickup/adStuckDiag.lua b/FS25_ADSmartPickup/adStuckDiag.lua index 0e982cc..07a3065 100644 --- a/FS25_ADSmartPickup/adStuckDiag.lua +++ b/FS25_ADSmartPickup/adStuckDiag.lua @@ -53,6 +53,16 @@ function ADStuckDiag.describe(vehicle) return table.concat(parts, " ") end +-- Kun læsning (statusvinduet): køretøjer der har holdt stille mindst minMs -> {{vehicle, ms}}, længst først. +function ADStuckDiag.stillVehicles(minMs) + local list = {} + for vehicle, ms in pairs(stillMs) do + if ms >= (minMs or ADStuckDiag.STILL_MS) then table.insert(list, {vehicle = vehicle, ms = ms}) end + end + table.sort(list, function(a, b) return a.ms > b.ms end) + return list +end + function ADStuckDiag.tick(dt) timerMs = timerMs + (dt or 0) if timerMs < ADStuckDiag.CHECK_INTERVAL_MS then return end diff --git a/FS25_ADSmartPickup/l10n/l10n_da.xml b/FS25_ADSmartPickup/l10n/l10n_da.xml index a5b8aa4..b94cbba 100644 --- a/FS25_ADSmartPickup/l10n/l10n_da.xml +++ b/FS25_ADSmartPickup/l10n/l10n_da.xml @@ -616,5 +616,21 @@ + + + + + + + + + + + + + + + + diff --git a/FS25_ADSmartPickup/l10n/l10n_en.xml b/FS25_ADSmartPickup/l10n/l10n_en.xml index 5cda597..504c156 100644 --- a/FS25_ADSmartPickup/l10n/l10n_en.xml +++ b/FS25_ADSmartPickup/l10n/l10n_en.xml @@ -616,5 +616,21 @@ + + + + + + + + + + + + + + + + diff --git a/FS25_ADSmartPickup/modDesc.xml b/FS25_ADSmartPickup/modDesc.xml index 5742a74..84c2851 100644 --- a/FS25_ADSmartPickup/modDesc.xml +++ b/FS25_ADSmartPickup/modDesc.xml @@ -62,6 +62,16 @@ + + + + + + + + + + diff --git a/docs/BRUGERMANUAL.md b/docs/BRUGERMANUAL.md index 8360688..a20c8a4 100644 --- a/docs/BRUGERMANUAL.md +++ b/docs/BRUGERMANUAL.md @@ -333,6 +333,25 @@ Sæt **Ja** ud for de bygninger, flowet skal bruge. Bygninger uden markør er sk **Vis alle bygninger** til. Listen er sorteret efter navn, så *Silo 9* kommer før *Silo 10*. En bygning, du har solgt, står som *Fjernet bygning (findes ikke mere) — sæt NEJ*. +### Statusvindue + +Tryk **venstre Alt + S** for at slå et lille statusvindue til eller fra øverst til højre på skærmen, lige under +spillets info-boks. Tasten virker både til fods og i et køretøj og kan ændres under *Indstillinger → Styring* +(*AD Smart Pickup: statusvindue*). Vinduet opdateres hvert sekund og viser kun de afsnit, der har indhold: + +- **Flows** — én linje pr. traktor i et kørende flow: *traktor · handling · fyld %* (fx *Milk Collect 1 · + henter Mælk ved Kostald · 45 %*). Grøn = kører, gul = venter (Wait, vogn), rød = flowet har en advarsel, + grå = stoppet. +- **Marker** — én linje pr. mark med markarbejde i gang: *F92 Høstes · Roeoptager · 45 %* (farve som i Marker-fanen). +- **Kæder** — kun kæder med et manglende led: *Brød mangler et led: Mel*. +- **Advarsler** — køretøjer der har stået stille i over 2 minutter (uden at Smart Pickup selv holder dem ved + Wait/udkørsel) og ventende kædeforslag (*Kæde: 2 forslag*). + +Højst 18 linjer; resten samles i *+k mere*. Vinduet skjules, mens en menu eller dialog er åben, når HUD'en er +skjult, og når du sover. Om vinduet er tændt, gemmes pr. bruger i +`Dokumenter/My Games/FarmingSimulator2025/modSettings/FS25_ADSmartPickup/status.xml` (ikke i savegamet); +standard er slukket. + --- ## 5. Forsyning: hold stalde og fabrikker fyldt diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index a2be993..d605934 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -336,6 +336,27 @@ Set **Yes** for the buildings the flow should use. Buildings without a marker ar on **Show all buildings**. The list is sorted by name, so *Silo 9* comes before *Silo 10*. A building you have sold appears as *Removed building (no longer exists) — set NO*. +### Status window + +Press **left Alt + S** to toggle a small status window in the top right corner of the screen, just below +the game's info box. The key works both on foot and in a vehicle and can be changed under *Settings → +Controls* (*AD Smart Pickup: status window*). The window refreshes every second and only shows sections +that have something in them: + +- **Flows** — one line per tractor in a running flow: *tractor · action · fill %* (e.g. *Milk Collect 1 · + fetching Milk at Cow Barn · 45 %*). Green = running, yellow = waiting (Wait, trailer), red = the flow has + a warning, grey = stopped. +- **Fields** — one line per field with field work in progress: *F92 Harvesting · Beet harvester · 45 %* + (colours as in the Fields tab). +- **Chains** — only chains with a missing link: *Bread is missing a link: Flour*. +- **Warnings** — vehicles that have stood still for more than 2 minutes (without Smart Pickup holding them at + Wait/outbound) and pending chain suggestions (*Chain: 2 suggestions*). + +At most 18 lines; the rest is summed up as *+k more*. The window is hidden while a menu or dialog is open, +when the HUD is hidden and while you sleep. Whether it is on is saved per user in +`Documents/My Games/FarmingSimulator2025/modSettings/FS25_ADSmartPickup/status.xml` (not in the savegame); +it is off by default. + --- ## 5. Supply: keep barns and factories filled