diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..c695de577 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# Courseplay Agent Guidelines + +## Code Style + +- **Always use getters to access member variables.** Do not read fields like `self.ppc.normalLookAheadDistance` directly from outside the owning class; call the appropriate getter instead (e.g. `self.ppc:getNormalLookaheadDistance()`). Add a getter to the class if one does not already exist. diff --git a/config/VehicleConfigurations.xml b/config/VehicleConfigurations.xml index 1d575c678..b4cb19eed 100644 --- a/config/VehicleConfigurations.xml +++ b/config/VehicleConfigurations.xml @@ -494,9 +494,6 @@ You can define the following custom settings: - - + diff --git a/modDesc.xml b/modDesc.xml index a24cfce9b..01321fedb 100644 --- a/modDesc.xml +++ b/modDesc.xml @@ -211,6 +211,7 @@ Changelog 8.1.0.3 + @@ -346,6 +347,7 @@ Changelog 8.1.0.3 + @@ -434,6 +436,10 @@ Changelog 8.1.0.3 + + + + @@ -463,6 +469,7 @@ Changelog 8.1.0.3 + \ No newline at end of file diff --git a/scripts/ai/CollisionAvoidanceController.lua b/scripts/ai/CollisionAvoidanceController.lua index f37b5f255..3e397d3c5 100644 --- a/scripts/ai/CollisionAvoidanceController.lua +++ b/scripts/ai/CollisionAvoidanceController.lua @@ -58,7 +58,8 @@ end function CollisionAvoidanceController:findPotentialCollisions() for _, vehicle in pairs(g_currentMission.vehicleSystem.vehicles) do - if AIDriveStrategyCombineCourse.isActiveCpCombine(vehicle) then + if AIDriveStrategyCombineCourse.isActiveCpCombine(vehicle) and + not (vehicle.cpIsManualCombineCallingUnloader and vehicle:cpIsManualCombineCallingUnloader()) then local d = calcDistanceFrom(self.vehicle.rootNode, vehicle.rootNode) if d < self.range then local myCourse = self.strategy:getCurrentCourse() diff --git a/scripts/ai/CpManualCombineProxy.lua b/scripts/ai/CpManualCombineProxy.lua new file mode 100644 index 000000000..531189827 --- /dev/null +++ b/scripts/ai/CpManualCombineProxy.lua @@ -0,0 +1,389 @@ +--- Proxy that mimics the AIDriveStrategyCombineCourse interface for manually-driven +--- combines, so the existing unloader strategy can interact with them without nil checks. +---@class CpManualCombineProxy +CpManualCombineProxy = CpObject() + +CpManualCombineProxy.activeCalls = {} +CpManualCombineProxy.DYNAMIC_COURSE_LENGTH = 100 +CpManualCombineProxy.COURSE_REFRESH_INTERVAL = 2000 + +function CpManualCombineProxy:init(vehicle) + self.vehicle = vehicle + self.unloader = CpTemporaryObject(nil) + self.timeToCallUnloader = CpTemporaryObject(true) + self.dynamicCourse = nil + self.lastCourseRefreshTime = 0 + self.measuredBackDistance = 5 + + self:findPipeImplement() + self:measureBackDistance() + self:refreshDynamicCourse() + + CpManualCombineProxy.activeCalls[vehicle] = self +end + +function CpManualCombineProxy:delete() + CpManualCombineProxy.activeCalls[self.vehicle] = nil + self.dynamicCourse = nil +end + +function CpManualCombineProxy:findPipeImplement() + self.pipeImplement = nil + self.pipeSpec = nil + for _, childVehicle in ipairs(self.vehicle:getChildVehicles()) do + if childVehicle.spec_pipe then + self.pipeImplement = childVehicle + self.pipeSpec = childVehicle.spec_pipe + break + end + end + if not self.pipeSpec and self.vehicle.spec_pipe then + self.pipeImplement = self.vehicle + self.pipeSpec = self.vehicle.spec_pipe + end +end + +function CpManualCombineProxy:measureBackDistance() + local backMarkerNode, _, _, backMarkerOffset = Markers.getBackMarkerNode(self.vehicle) + if backMarkerOffset then + self.measuredBackDistance = math.abs(backMarkerOffset) + end +end + +--- Generates a straight course from the combine's current position and heading. +function CpManualCombineProxy:refreshDynamicCourse() + self.dynamicCourse = Course.createStraightForwardCourse(self.vehicle, + self.DYNAMIC_COURSE_LENGTH, 0, self.vehicle:getAIDirectionNode()) + self.lastCourseRefreshTime = g_time +end + +function CpManualCombineProxy:update(dt) + if g_time - self.lastCourseRefreshTime > self.COURSE_REFRESH_INTERVAL then + self:refreshDynamicCourse() + end + self:callUnloaderWhenNeeded() +end + +------------------------------------------------------------------------------------------------------------------------ +-- Unloader calling: simplified version that always calls with the combine's current position +------------------------------------------------------------------------------------------------------------------------ +function CpManualCombineProxy:callUnloaderWhenNeeded() + if not self.timeToCallUnloader:get() then + return + end + self.timeToCallUnloader:set(false, 1500) + + if self.unloader:get() then + return + end + + local bestUnloader = self:findUnloader() + if bestUnloader then + local strategy = bestUnloader:getCpDriveStrategy() + if strategy and strategy.call then + strategy:call(self.vehicle, nil) + end + end +end + +function CpManualCombineProxy:findUnloader() + local bestScore = -math.huge + local bestUnloader + for _, vehicle in pairs(g_currentMission.vehicleSystem.vehicles) do + if AIDriveStrategyUnloadCombine.isActiveCpCombineUnloader(vehicle) then + local x, _, z = getWorldTranslation(self.vehicle.rootNode) + local driveStrategy = vehicle:getCpDriveStrategy() + if driveStrategy:isServingPosition(x, z, 10) then + local fillPct = driveStrategy:getFillLevelPercentage() + if driveStrategy:isAllowedToBeCalled() and fillPct < 99 then + local dist, _ = driveStrategy:getDistanceAndEteToVehicle(self.vehicle) + local score = fillPct - 0.1 * dist + if score > bestScore then + bestUnloader = vehicle + bestScore = score + end + end + end + end + end + return bestUnloader +end + +------------------------------------------------------------------------------------------------------------------------ +-- Interface methods that mimic AIDriveStrategyCombineCourse for the unloader +------------------------------------------------------------------------------------------------------------------------ + +function CpManualCombineProxy:registerUnloader(driver) + self.unloader:set(driver, 1000) +end + +function CpManualCombineProxy:deregisterUnloader(driver, noEventSend) + self.unloader:reset() +end + +function CpManualCombineProxy:hasAutoAimPipe() + if self.pipeSpec then + return self.pipeSpec.numAutoAimingStates > 0 + end + return false +end + +function CpManualCombineProxy:getFillType() + if self.pipeImplement and self.pipeImplement.getDischargeNodeByIndex then + local dischargeNode = self.pipeImplement:getDischargeNodeByIndex( + self.pipeImplement:getPipeDischargeNodeIndex()) + if dischargeNode then + return self.pipeImplement:getFillUnitFillType(dischargeNode.fillUnitIndex) + end + end + return FillType.UNKNOWN +end + +function CpManualCombineProxy:isDischarging() + if self.pipeImplement and self.pipeImplement.getDischargeState then + return self.pipeImplement:getDischargeState() ~= Dischargeable.DISCHARGE_STATE_OFF + end + return false +end + +function CpManualCombineProxy:getPipeOffset(additionalOffsetX, additionalOffsetZ) + local pipeOffsetX, pipeOffsetZ = 0, 0 + if self.pipeSpec then + local pipeNode = self.pipeSpec.nodes and self.pipeSpec.nodes[1] + if pipeNode and pipeNode.node and entityExists(pipeNode.node) then + pipeOffsetX, _, pipeOffsetZ = localToLocal(pipeNode.node, + self.vehicle:getAIDirectionNode(), 0, 0, 0) + else + pipeOffsetX = self.vehicle:getCpSettings().pipeOffsetX:getValue() + pipeOffsetZ = self.vehicle:getCpSettings().pipeOffsetZ:getValue() + end + end + return pipeOffsetX + (additionalOffsetX or 0), pipeOffsetZ + (additionalOffsetZ or 0), self:hasAutoAimPipe() +end + +function CpManualCombineProxy:isPipeMoving() + if self.pipeSpec then + return self.pipeSpec.currentState == 0 + end + return false +end + +function CpManualCombineProxy:getPipeOffsetReferenceNode() + return self.vehicle:getAIDirectionNode() +end + +function CpManualCombineProxy:getAreaToAvoid() + return nil +end + +function CpManualCombineProxy:getMeasuredBackDistance() + return self.measuredBackDistance +end + +function CpManualCombineProxy:getFieldworkCourse() + return self.dynamicCourse +end + +function CpManualCombineProxy:getClosestFieldworkWaypointIx() + return 1 +end + +function CpManualCombineProxy:getWorkWidth() + if self.vehicle.getCpSettings then + local settings = self.vehicle:getCpSettings() + if settings and settings.workWidth then + return settings.workWidth:getValue() + end + end + return 6 +end + +function CpManualCombineProxy:getFruitAtSides() + return nil, nil +end + +------------------------------------------------------------------------------------------------------------------------ +-- State queries: safe defaults for a manually-driven combine +------------------------------------------------------------------------------------------------------------------------ + +function CpManualCombineProxy:isWaitingForUnload() + -- Return true only when the combine is actually stopped so the unloader strategy + -- transitions correctly between moving-combine and stopped-combine unload states, + -- and so the deadlock detector does not misfire when the grain cart is simply pausing. + return AIUtil.isStopped(self.vehicle) +end + +function CpManualCombineProxy:isWaitingForUnloadAfterPulledBack() + return false +end + +function CpManualCombineProxy:isWaitingForUnloadAfterCourseEnded() + return false +end + +function CpManualCombineProxy:willWaitForUnloadToFinish() + local stopped = AIUtil.isStopped(self.vehicle) + if stopped then + -- Debounce: only report "waiting" after the combine has been stopped for 3+ continuous seconds. + -- A momentary GPS micro-correction or terrain hitch would otherwise cause isInFrontAndAligned- + -- ToMovingCombine() to return false, which combined with the grain cart being in the normal + -- forward-of-direction-node unloading position (dz>0) kills isBehindAndAlignedToCombine() too, + -- triggering the dreaded startWaitingForSomethingToDo() → tarp cycle. + if not self._stoppedSinceTime then + self._stoppedSinceTime = g_time + CpUtil.debugVehicle(CpDebug.DBG_UNLOAD, self.vehicle, + 'CpManualCombineProxy:willWaitForUnloadToFinish: combine stopped, starting 3s debounce') + end + local waitingLongEnough = g_time - self._stoppedSinceTime > 3000 + if waitingLongEnough then + CpUtil.debugVehicle(CpDebug.DBG_UNLOAD, self.vehicle, + 'CpManualCombineProxy:willWaitForUnloadToFinish: combine stopped >3s, returning true') + end + return waitingLongEnough + else + if self._stoppedSinceTime then + CpUtil.debugVehicle(CpDebug.DBG_UNLOAD, self.vehicle, + 'CpManualCombineProxy:willWaitForUnloadToFinish: combine moving again (was stopped %.1fs)', + (g_time - self._stoppedSinceTime) / 1000) + end + self._stoppedSinceTime = nil + return false + end +end + +function CpManualCombineProxy:alwaysNeedsUnloader() + return false +end + +function CpManualCombineProxy:isReadyToUnload(noUnloadWithPipeInFruit) + return true +end + +function CpManualCombineProxy:isTurning() + return false +end + +function CpManualCombineProxy:isTurningButNotEndingTurn() + return false +end + +function CpManualCombineProxy:isTurnForwardOnly() + return false +end + +function CpManualCombineProxy:getTurnCourse() + return nil +end + +function CpManualCombineProxy:isFinishingRow() + return false +end + +function CpManualCombineProxy:isAboutToTurn() + return false +end + +function CpManualCombineProxy:isAboutToReturnFromPocket() + return false +end + +function CpManualCombineProxy:isManeuvering() + return false +end + +function CpManualCombineProxy:isOnHeadland(n) + return false +end + +function CpManualCombineProxy:isReversing() + return false +end + +function CpManualCombineProxy:isIdle() + return false +end + +function CpManualCombineProxy:hold(ms) +end + +function CpManualCombineProxy:requestToIgnoreProximity(vehicle) +end + +function CpManualCombineProxy:requestToMoveForward(requestingVehicle) +end + +function CpManualCombineProxy:reconfirmRendezvous() +end + +function CpManualCombineProxy:hasRendezvousWith(unloader) + return false +end + +function CpManualCombineProxy:getTurnArea() + return nil, nil +end + +function CpManualCombineProxy:isUnloadFinished() + if self:isDischarging() then + self._wasDischarging = true + self._dischargeOffTime = nil + return false + end + if self._wasDischarging then + -- Require discharge to be off for 2 continuous seconds before declaring done. + -- This prevents a momentary aim-miss (grain cart swerve, brief obstruction) from + -- prematurely ending the unload cycle and forcing the grain cart to drive away and return. + if not self._dischargeOffTime then + self._dischargeOffTime = g_time + CpUtil.debugVehicle(CpDebug.DBG_UNLOAD, self.vehicle, + 'CpManualCombineProxy:isUnloadFinished: discharge stopped, starting 2s debounce') + end + local elapsed = g_time - self._dischargeOffTime + if elapsed > 2000 then + CpUtil.debugVehicle(CpDebug.DBG_UNLOAD, self.vehicle, + 'CpManualCombineProxy:isUnloadFinished: discharge off for %.1fs, returning TRUE (unload done)', elapsed / 1000) + self._wasDischarging = false + self._dischargeOffTime = nil + return true + end + CpUtil.debugVehicle(CpDebug.DBG_UNLOAD, self.vehicle, + 'CpManualCombineProxy:isUnloadFinished: discharge off for %.1fs, waiting for 2s debounce', elapsed / 1000) + end + return false +end + +function CpManualCombineProxy:getFillLevelPercentage() + -- The farmer is in full control. Fill level must never cause the grain cart to leave — + -- the only valid exit is the pipe closing (isUnloadFinished). Always report full. + return 1 +end + +function CpManualCombineProxy:isTurningOnHeadland() + return false +end + +function CpManualCombineProxy:getTurnStartWpIx() + return 1 +end + +function CpManualCombineProxy:isProcessingFruit() + return false +end + +--- Marker so the unloader strategy can identify this as a manual-combine proxy +--- purely from the strategy object, without re-querying the vehicle. +function CpManualCombineProxy:isManualProxy() + return true +end + +function CpManualCombineProxy:isWaitingInPocket() + return false +end + +function CpManualCombineProxy:isActiveCpCombine() + return true +end + +function CpManualCombineProxy:getUnloadTargetType() + return nil +end diff --git a/scripts/ai/PurePursuitController.lua b/scripts/ai/PurePursuitController.lua index 4a8982985..67eba3843 100644 --- a/scripts/ai/PurePursuitController.lua +++ b/scripts/ai/PurePursuitController.lua @@ -236,6 +236,10 @@ function PurePursuitController:getLookaheadDistance() return self.lookAheadDistance end +function PurePursuitController:getNormalLookaheadDistance() + return self.normalLookAheadDistance +end + function PurePursuitController:setCurrentLookaheadDistance(cte) local la = self.temporaryLookAheadDistance:get() or self.baseLookAheadDistance self.lookAheadDistance = math.min(la + math.abs(cte), la * 2) diff --git a/scripts/ai/strategies/AIDriveStrategyUnloadCombine.lua b/scripts/ai/strategies/AIDriveStrategyUnloadCombine.lua index eecc77c80..ad95939c3 100644 --- a/scripts/ai/strategies/AIDriveStrategyUnloadCombine.lua +++ b/scripts/ai/strategies/AIDriveStrategyUnloadCombine.lua @@ -318,12 +318,51 @@ function AIDriveStrategyUnloadCombine:initializeImplementControllers(vehicle) self:addImplementController(vehicle, FoldableController, Foldable, {}) end +--- Gets the combine's drive strategy or manual combine proxy. +--- Use this instead of combineToUnload:getCpDriveStrategy() to support manual combines. +---@return AIDriveStrategyCombineCourse|CpManualCombineProxy|nil +function AIDriveStrategyUnloadCombine:getCombineStrategy() + if self.combineToUnload then + -- Manual-combine proxy takes priority: if the farmer has explicitly called an unloader + -- the proxy is the correct interface regardless of any CP strategy that may be present. + if self.combineToUnload.cpGetManualCombineProxy then + local proxy = self.combineToUnload:cpGetManualCombineProxy() + if proxy then return proxy end + end + if self.combineToUnload.getCpDriveStrategy then + return self.combineToUnload:getCpDriveStrategy() + end + end + return nil +end + +--- Checks if the assigned combine is active (either CP-driven or manually calling a grain cart). +---@return boolean +function AIDriveStrategyUnloadCombine:isCombineActive() + if self.combineToUnload then + if self.combineToUnload.getIsCpActive and self.combineToUnload:getIsCpActive() then + return true + end + if self.combineToUnload.cpIsManualCombineCallingUnloader and self.combineToUnload:cpIsManualCombineCallingUnloader() then + return true + end + end + return false +end + function AIDriveStrategyUnloadCombine:isProximitySpeedControlEnabled() - return not (self.state == self.states.UNLOADING_MOVING_COMBINE and self.combineToUnload:getCpDriveStrategy():hasAutoAimPipe()) + local strategy = self:getCombineStrategy() + return not (self.state == self.states.UNLOADING_MOVING_COMBINE and strategy and strategy:hasAutoAimPipe()) end function AIDriveStrategyUnloadCombine:ignoreProximityObject(object, vehicle, moveForwards, hitTerrain) return (self.state == self.states.UNLOADING_ON_THE_FIELD and hitTerrain) or + -- Straw windrow piles are height-map physics objects. Their raycasts register as terrain + -- hits, causing the proximity sensor to bleed speed when crossing perpendicular. + -- The pathfinder has already routed around real terrain obstacles, so terrain hits + -- during approach and unloading are safe to ignore. + (self.state == self.states.DRIVING_TO_COMBINE and hitTerrain) or + (self.state == self.states.UNLOADING_MOVING_COMBINE and hitTerrain) or -- these states handle the proximity by themselves (self.state == self.states.UNLOADING_MOVING_COMBINE and vehicle == self.combineToUnload) or (self.state == self.states.HANDLE_CHOPPER_HEADLAND_TURN and vehicle == self.combineToUnload) @@ -343,6 +382,15 @@ end function AIDriveStrategyUnloadCombine:getDriveData(dt, vX, vY, vZ) self:updateLowFrequencyImplementControllers() + -- While a manual-combine proxy is our target, disable the PPC off-track shutdown continuously. + -- The placeholder follow course is static and the cart will drift from it as the combine moves, + -- so the off-track check would otherwise stop the job. The timeout (5000 ms) is much larger + -- than any realistic frame interval, so as long as this runs every frame the check stays off. + local combineStrategyForOffTrack = self:getCombineStrategy() + if combineStrategyForOffTrack and combineStrategyForOffTrack.isManualProxy then + self.ppc:disableStopWhenOffTrack(5000) + end + -- if applicable, calculate on which side of an auto aim pipe we should be driving, once every loop self:calculateAutoAimPipeOffsetX(self.combineToUnload) @@ -359,8 +407,8 @@ function AIDriveStrategyUnloadCombine:getDriveData(dt, vX, vY, vZ) end -- make sure if we have a combine we stay registered - if self.combineToUnload and self.combineToUnload:getIsCpActive() then - local strategy = self.combineToUnload:getCpDriveStrategy() + if self.combineToUnload and self:isCombineActive() then + local strategy = self:getCombineStrategy() if strategy then if strategy.registerUnloader then strategy:registerUnloader(self) @@ -373,7 +421,7 @@ function AIDriveStrategyUnloadCombine:getDriveData(dt, vX, vY, vZ) end end - if self.combineToUnload == nil or not self.combineToUnload:getIsCpActive() then + if self.combineToUnload == nil or not self:isCombineActive() then if CpUtil.isStateOneOf(self.state, self.combineUnloadStates) then end @@ -409,7 +457,7 @@ function AIDriveStrategyUnloadCombine:getDriveData(dt, vX, vY, vZ) self:setMaxSpeed(0) elseif self.state == self.states.IDLE then -- nothing to do right now, wait for one of the following: - -- - combine calls + -- - combine calls (including manual combines via proxy) -- - user sends us to unload the trailer -- - a trailer appears where we can unload our auger wagon if full self:setMaxSpeed(0) @@ -446,7 +494,7 @@ function AIDriveStrategyUnloadCombine:getDriveData(dt, vX, vY, vZ) elseif self.state == self.states.UNLOADING_MOVING_COMBINE then local x, z - if self.combineToUnload:getCpDriveStrategy():hasAutoAimPipe() then + if self:getCombineStrategy():hasAutoAimPipe() then x, z = self:unloadMovingChopper() else x, z = self:unloadMovingCombine(dt) @@ -487,7 +535,7 @@ function AIDriveStrategyUnloadCombine:getDriveData(dt, vX, vY, vZ) self:setMaxSpeed(self.settings.reverseSpeed:getValue()) if self.state.properties.holdCombine then self:debugSparse('Holding combine while backing up') - self.combineToUnload:getCpDriveStrategy():hold(1000) + self:getCombineStrategy():hold(1000) end -- drive back until the combine is in front of us local _, _, dz = self:getDistanceFromCombine(self.state.properties.vehicle) @@ -544,7 +592,7 @@ end function AIDriveStrategyUnloadCombine:hasToWaitForAssignedCombine() if CpUtil.isStateOneOf(self.state, self.combineUnloadStates) then - return self.combineToUnload == nil or not self.combineToUnload:getIsCpActive() or self.combineToUnload:getCpDriveStrategy() == nil + return self.combineToUnload == nil or not self:isCombineActive() or self:getCombineStrategy() == nil end return false end @@ -572,10 +620,11 @@ function AIDriveStrategyUnloadCombine:startWaitingForSomethingToDo() end end + ---@return table|nil the best node (of all the fill nodes on all trailers) to use to unload a harvester function AIDriveStrategyUnloadCombine:getBestTargetNode() local function isValidNode(targetNode) - local fillType = self.combineToUnload:getCpDriveStrategy():getFillType() + local fillType = self:getCombineStrategy():getFillType() -- for some harvesters (DeWulf), fill type is unknown until they start working if fillType ~= FillType.UNKNOWN and not targetNode.trailer:getFillUnitAllowsFillType(targetNode.fillUnitIx, fillType) then self:debugSparse("Fill node %d of trailer %s doesn't accept fillType %s!", @@ -622,9 +671,11 @@ end ---@return number | nil, number | nil gx, gz world coordinates to steer to, instead of the PPC determined goal point (which is --- calculated from the offset harvester course). --- This goal point is calculated from the harvester's position. It is on a straight line parallel to the harvester, ---- under the pipe and look ahead distance ahead of the unloader +--- under the pipe and look-ahead distance ahead of the unloader. --- driveBesideCombine() creates this goal when approaching the harvester to align with the pipe better and faster than --- just using the offset course waypoints. +--- For manually-driven combines this goal is computed on every frame (bypassing the placeholder course entirely) so +--- the unloader stays aligned through curves using the live pipe reference node. function AIDriveStrategyUnloadCombine:driveBesideCombine() local dz = self:getBestTargetNodeDistanceFromPipe() @@ -632,7 +683,7 @@ function AIDriveStrategyUnloadCombine:driveBesideCombine() return end - local strategy = self.combineToUnload:getCpDriveStrategy() + local strategy = self:getCombineStrategy() -- use a factor to make sure we reach the pipe fast, but be more gentle while discharging local factor = strategy:isDischarging() and 0.75 or 2 local combineSpeed = self.combineToUnload.lastSpeedReal * 3600 @@ -653,12 +704,27 @@ function AIDriveStrategyUnloadCombine:driveBesideCombine() CpUtil.getName(self.vehicle), dz, speed, factor) local gx, gy, gz + -- For manually-driven combines we cannot rely on a fieldwork course to steer by — there is + -- none. Always compute the direct goal point (regardless of dz) so steering is derived from + -- the live pipe reference node and stays locked to the combine's current heading, including + -- through curves. For CP-driven combines keep the original behaviour: only override the PPC + -- course-based goal point when the cart is still far behind the pipe (dz > 5). + local isManual = strategy.isManualProxy and strategy:isManualProxy() -- Calculate an artificial goal point relative to the harvester to align better when starting to unload - if dz > 5 then + if dz > 5 or isManual then _, _, dz = localToLocal(self.vehicle:getAIDirectionNode(), self:getPipeOffsetReferenceNode(), 0, 0, 0) + -- For manual combines: use the vehicle's natural (non-CTE-adjusted) lookahead distance. + -- getLookaheadDistance() can be inflated up to 2x the base value when the cart is far from + -- the placeholder course. A 12 m lookahead puts the goal point too far ahead for responsive + -- curve following. getNormalLookaheadDistance() is constant (≈5-6 m) and not inflated by + -- cross-track error, so turns are tracked much more tightly. + -- For CP-driven combines the inflated lookahead is appropriate (they follow a real course). + local lookahead = isManual + and self.ppc:getNormalLookaheadDistance() + or self.ppc:getLookaheadDistance() gx, gy, gz = localToWorld(self:getPipeOffsetReferenceNode(), -- straight line parallel to the harvester, under the pipe, look ahead distance from the unloader - self:getPipeOffset(self.combineToUnload), 0, dz + self.ppc:getLookaheadDistance()) + self:getPipeOffset(self.combineToUnload), 0, dz + lookahead) if CpUtil.isVehicleDebugActive(self.vehicle) and CpDebug:isChannelActive(self.debugChannel) then -- show the goal point @@ -674,7 +740,8 @@ end ------------------------------------------------------------------------------------------------------------------------ function AIDriveStrategyUnloadCombine:isInDeadlock() if self.combineToUnload then - local combineStrategy = self.combineToUnload:getCpDriveStrategy() + local combineStrategy = self:getCombineStrategy() + if not combineStrategy then return false end if self.inDeadlock == nil then self.inDeadlock = CpDelayedBoolean() end @@ -703,7 +770,7 @@ function AIDriveStrategyUnloadCombine:unloadMovingChopper() return end - local combineStrategy = self.combineToUnload:getCpDriveStrategy() + local combineStrategy = self:getCombineStrategy() local gx, gz = self:followChopper() if combineStrategy:isTurning() and not combineStrategy:isFinishingRow() then @@ -792,10 +859,10 @@ function AIDriveStrategyUnloadCombine:handleChopper180Turn() return end - if self.combineToUnload:getCpDriveStrategy():isTurningButNotEndingTurn() then - if self.combineToUnload:getCpDriveStrategy():isTurnForwardOnly() then + if self:getCombineStrategy():isTurningButNotEndingTurn() then + if self:getCombineStrategy():isTurnForwardOnly() then ---@type Course - local turnCourse = self.combineToUnload:getCpDriveStrategy():getTurnCourse() + local turnCourse = self:getCombineStrategy():getTurnCourse() if turnCourse then self:debug('Follow chopper through the turn') self:startCourse(turnCourse:copy(self.vehicle), 1) @@ -868,7 +935,8 @@ end function AIDriveStrategyUnloadCombine:handleChopperTurn(harvester) -- since we are taking care of staying away, ask the chopper to ignore us - harvester:getCpDriveStrategy():requestToIgnoreProximity(self.vehicle) + local harvesterStrategy = self:getCombineStrategy() + harvesterStrategy:requestToIgnoreProximity(self.vehicle) local d, dx, dz = self:getDistanceFromCombine(harvester) local combineSpeed = harvester.lastSpeedReal * 3600 @@ -886,12 +954,12 @@ function AIDriveStrategyUnloadCombine:handleChopperTurn(harvester) -- stay closer when still discharging if sameDirection then -- reverse speed is controlled around combine's speed - dReference = harvester:getCpDriveStrategy():isDischarging() and dz or dz - 3 + dReference = harvesterStrategy:isDischarging() and dz or dz - 3 speed = combineSpeed + CpMathUtil.clamp(self.targetDistanceBehindChopper - dReference, -combineSpeed, self.settings.reverseSpeed:getValue() * 1.5) else -- reverse speed only depends on distance from the combine, stop when at working width - speed = CpMathUtil.clamp(harvester:getCpDriveStrategy():getWorkWidth() - d, 0, + speed = CpMathUtil.clamp(harvesterStrategy:getWorkWidth() - d, 0, self.settings.reverseSpeed:getValue() * 1.5) end else @@ -926,8 +994,8 @@ function AIDriveStrategyUnloadCombine:followChopperThroughTurn() end local d = self:getDistanceFromCombine() - local turnCourse = self.combineToUnload:getCpDriveStrategy():getTurnCourse() - if self.combineToUnload:getCpDriveStrategy():isTurning() and turnCourse ~= nil then + local turnCourse = self:getCombineStrategy():getTurnCourse() + if self:getCombineStrategy():isTurning() and turnCourse ~= nil then -- making sure we are never ahead of the chopper on the course (we both drive the same course), this -- prevents the unloader cutting in front of the chopper when for example the unloader is on the -- right side of the chopper and the chopper reaches a right turn. @@ -951,9 +1019,13 @@ end --- side of the chopper is already harvested, or behind it if both sides have fruit. ------------------------------------------------------------------------------------------------------------------------ function AIDriveStrategyUnloadCombine:calculateAutoAimPipeOffsetX(harvester) - local strategy = harvester and harvester:getCpDriveStrategy() + local strategy = harvester and self:getCombineStrategy() if strategy and strategy.hasAutoAimPipe and strategy:hasAutoAimPipe() then local fruitLeft, fruitRight = strategy:getFruitAtSides() + -- getFruitAtSides() can return nil before checkFruit() has run (strategy just created, + -- or proxy returns nil, nil). Default to 0 so the arithmetic below doesn't crash. + fruitLeft = fruitLeft or 0 + fruitRight = fruitRight or 0 local targetOffsetX, distanceBetweenVehicles = 0, (AIUtil.getWidth(harvester) + AIUtil.getWidth(self.vehicle)) / 2 + 1 -- we use 20% of the average as a threshold for significant difference local fruitThreshold = 0.2 * 0.5 * (fruitLeft + fruitRight) @@ -1080,14 +1152,14 @@ function AIDriveStrategyUnloadCombine:getPipesBaseNode(combine) end function AIDriveStrategyUnloadCombine:getCombineIsTurning() - return self.combineToUnload:getCpDriveStrategy() and self.combineToUnload:getCpDriveStrategy():isTurning() + return self:getCombineStrategy() and self:getCombineStrategy():isTurning() end ---@return number, number x and z offset of the pipe's end from the combine's root node in the Giants coordinate system ---(x > 0 left, z > 0 forward) corrected with the manual offset settings function AIDriveStrategyUnloadCombine:getPipeOffset(combine) - local offsetX, offsetZ = combine:getCpDriveStrategy():getPipeOffset(-self.settings.combineOffsetX:getValue(), self.settings.combineOffsetZ:getValue()) - if combine:getCpDriveStrategy():hasAutoAimPipe() then + local offsetX, offsetZ = self:getCombineStrategy():getPipeOffset(-self.settings.combineOffsetX:getValue(), self.settings.combineOffsetZ:getValue()) + if self:getCombineStrategy():hasAutoAimPipe() then return self:getAutoAimPipeOffsetX(), offsetZ else return offsetX, offsetZ @@ -1097,7 +1169,9 @@ end ---@return number offset X for the course to follow the combine, this is the pipe offset and the combine courser offset function AIDriveStrategyUnloadCombine:getFollowingCourseOffset(combine) local pipeOffset = self:getPipeOffset(combine) - local courseOffset = combine:getCpDriveStrategy():getFieldworkCourse():getOffset() + local combineStrategy = self:getCombineStrategy() + local fieldworkCourse = combineStrategy and combineStrategy:getFieldworkCourse() + local courseOffset = fieldworkCourse and fieldworkCourse:getOffset() or 0 return -pipeOffset + courseOffset end @@ -1106,7 +1180,7 @@ function AIDriveStrategyUnloadCombine:getAutoAimPipeOffsetX() end function AIDriveStrategyUnloadCombine:getCombinesMeasuredBackDistance() - return self.combineToUnload:getCpDriveStrategy():getMeasuredBackDistance() + return self:getCombineStrategy():getMeasuredBackDistance() end function AIDriveStrategyUnloadCombine:getAllTrailersFull(fullThresholdPercentage) @@ -1137,8 +1211,8 @@ end function AIDriveStrategyUnloadCombine:releaseCombine() self.combineJustUnloaded = nil - if self.combineToUnload and self.combineToUnload:getIsCpActive() then - local strategy = self.combineToUnload:getCpDriveStrategy() + if self.combineToUnload and self:isCombineActive() then + local strategy = self:getCombineStrategy() if strategy and strategy.deregisterUnloader then strategy:deregisterUnloader(self) end @@ -1232,7 +1306,7 @@ end function AIDriveStrategyUnloadCombine:isBehindAndAlignedToCombine(debugEnabled) -- if the harvester has an auto aim pipe, like a chopper we can relax our conditions - local hasAutoAimPipe = self.combineToUnload:getCpDriveStrategy():hasAutoAimPipe() + local hasAutoAimPipe = self:getCombineStrategy():hasAutoAimPipe() local dx, _, dz = localToLocal(self.vehicle.rootNode, self:getPipeOffsetReferenceNode(), 0, 0, 0) local pipeOffset = self:getPipeOffset(self.combineToUnload) if dz > (hasAutoAimPipe and -5 or 0) then @@ -1280,11 +1354,11 @@ function AIDriveStrategyUnloadCombine:isInFrontAndAlignedToMovingCombine(debugEn AIDriveStrategyUnloadCombine.maxDirectionDifferenceDeg) return false end - if self.combineToUnload:getCpDriveStrategy():willWaitForUnloadToFinish() then + if self:getCombineStrategy():willWaitForUnloadToFinish() then self:debugIf(debugEnabled, 'isInFrontAndAlignedToMovingCombine: combine is not moving') return false end - if self.combineToUnload:getCpDriveStrategy():alwaysNeedsUnloader() then + if self:getCombineStrategy():alwaysNeedsUnloader() then -- this harvester won't move without an unloader under the pipe, so if our fill node is in front of the -- trailer, there is no point waiting for it dz = self:getBestTargetNodeDistanceFromPipe() @@ -1299,7 +1373,7 @@ function AIDriveStrategyUnloadCombine:isInFrontAndAlignedToMovingCombine(debugEn end function AIDriveStrategyUnloadCombine:isOkToStartUnloadingCombine() - if self.combineToUnload:getCpDriveStrategy():isReadyToUnload(true) then + if self:getCombineStrategy():isReadyToUnload(true) then -- if it always needs an unloader, it won't move without it, so can't start unloading when in front of the combine return self:isBehindAndAlignedToCombine() or self:isInFrontAndAlignedToMovingCombine() else @@ -1362,7 +1436,7 @@ end -- Start to unload the combine (driving to the pipe/closer to combine) ------------------------------------------------------------------------------------------------------------------------ function AIDriveStrategyUnloadCombine:startUnloadingCombine() - if self.combineToUnload:getCpDriveStrategy():willWaitForUnloadToFinish() then + if self:getCombineStrategy():willWaitForUnloadToFinish() then self:debug('Close enough to a stopped combine, drive to pipe') self:startUnloadingStoppedCombine() else @@ -1388,8 +1462,23 @@ end ---@return Course fieldwork course of the combine ---@return number approximate waypoint index of the combine's current position function AIDriveStrategyUnloadCombine:setupFollowCourse() + local combineStrategy = self:getCombineStrategy() + -- For manually-driven combines there is no fieldwork course. Build a short placeholder + -- starting at the combine's current position and heading. The PPC needs a course to + -- initialise against, but driveBesideCombine() overrides all steering with a live goal + -- point derived from the pipe reference node, so this course is never actually followed. + if combineStrategy and combineStrategy:isManualProxy() then + local placeholder = Course.createStraightForwardCourse(self.combineToUnload, 100, 0, + self.combineToUnload:getAIDirectionNode()) + if placeholder then + self:debug('Manual combine: created placeholder follow course (steering via driveBesideCombine)') + return placeholder, 1 + end + self:debugSparse('Manual combine: failed to create placeholder course') + return + end ---@type Course - self.combineCourse = self.combineToUnload:getCpDriveStrategy():getFieldworkCourse() + self.combineCourse = combineStrategy:getFieldworkCourse() if not self.combineCourse then -- TODO: handle this more gracefully, or even better, don't even allow selecting combines with no course self:debugSparse('Waiting for combine to set up a course, can\'t follow') @@ -1397,7 +1486,7 @@ function AIDriveStrategyUnloadCombine:setupFollowCourse() end local followCourse = self.combineCourse:copy(self.vehicle) -- relevant waypoint is the closest to the combine, prefer that so our PPC will get us on course with the proper offset faster - local followCourseIx = self.combineToUnload:getCpDriveStrategy():getClosestFieldworkWaypointIx() or self.combineCourse:getCurrentWaypointIx() + local followCourseIx = combineStrategy:getClosestFieldworkWaypointIx() or self.combineCourse:getCurrentWaypointIx() return followCourse, followCourseIx end @@ -1422,7 +1511,7 @@ function AIDriveStrategyUnloadCombine:getCombineToUnload() end function AIDriveStrategyUnloadCombine:getPipeOffsetReferenceNode() - return self.combineToUnload:getCpDriveStrategy():getPipeOffsetReferenceNode() + return self:getCombineStrategy():getPipeOffsetReferenceNode() end ------------------------------------------------------------------------------------------------------------------------ @@ -1467,16 +1556,15 @@ end ------------------------------------------------------------------------------------------------------------------------ -- Pathfinding to waiting (not moving) combine ------------------------------------------------------------------------------------------------------------------------ -function AIDriveStrategyUnloadCombine:startPathfindingToWaitingCombine(xOffset, zOffset) +function AIDriveStrategyUnloadCombine:startPathfindingToWaitingCombine(xOffset, zOffset, failureCallback) local context = PathfinderContext(self.vehicle) - local maxFruitPercent = self:getMaxFruitPercent(self:getPipeOffsetReferenceNode(), xOffset, zOffset) - context:maxFruitPercent(maxFruitPercent) + context:maxFruitPercent(self:getMaxFruitPercent(self:getPipeOffsetReferenceNode(), xOffset, zOffset)) context:offFieldPenalty(self:getOffFieldPenalty(self.combineToUnload)) context:useFieldNum(CpFieldUtil.getFieldNumUnderVehicle(self.combineToUnload)) - context:areaToAvoid(self.combineToUnload:getCpDriveStrategy():getAreaToAvoid()) + context:areaToAvoid(self:getCombineStrategy():getAreaToAvoid()) context:vehiclesToIgnore({}):maxIterations(PathfinderUtil.getMaxIterationsForFieldPolygon(self.vehicle:cpGetFieldPolygon())) self.pathfinderController:registerListeners(self, self.onPathfindingDoneToWaitingCombine, - self.onPathfindingFailedToStationaryTarget, self.onPathfindingObstacleAtStart) + failureCallback or self.onPathfindingFailedToStationaryTarget, self.onPathfindingObstacleAtStart) self.pathfinderController:findPathToNode(context, self:getPipeOffsetReferenceNode(), xOffset or 0, zOffset or 0, 3) end @@ -1485,6 +1573,12 @@ function AIDriveStrategyUnloadCombine:onPathfindingDoneToWaitingCombine(controll self:debug('Pathfinding to waiting combine successful') course:adjustForReversing(math.max(1, -AIUtil.getDirectionNodeToReverserNodeOffset(self.vehicle))) self:startCourse(course, 1) + -- Record the combine's position now so driveToCombine() can detect if it has moved + -- significantly and needs a re-path. Also reset the check timer so the first periodic + -- check fires 5 s after we start driving, not immediately. + local cX, _, cZ = getWorldTranslation(self:getPipeOffsetReferenceNode()) + self.combinePositionAtApproachStart = { x = cX, z = cZ } + self.lastCombinePositionCheckTime = g_time self:setNewState(self.states.DRIVING_TO_COMBINE) return true else @@ -1624,12 +1718,12 @@ end --- unloader to come to the combine. ---@return boolean true if the unloader has accepted the request function AIDriveStrategyUnloadCombine:call(combine, waypoint) + self.combineToUnload = combine local xOffset, zOffset = self:getPipeOffset(combine) if waypoint then -- combine set up a rendezvous waypoint for us, go there if self:isPathfindingNeeded(self.vehicle, waypoint, xOffset, zOffset, 25) then self.rendezvousWaypoint = waypoint - self.combineToUnload = combine -- just in case, as the combine may give us a rendezvous waypoint -- where it is full, make sure we are behind the combine zOffset = -self:getCombinesMeasuredBackDistance() - 5 @@ -1646,13 +1740,13 @@ function AIDriveStrategyUnloadCombine:call(combine, waypoint) -- combine wants us to drive directly to it self:debug('call: Combine is waiting for unload, start finding path to combine') self.combineToUnload = combine - if self.combineToUnload:getCpDriveStrategy():isWaitingForUnloadAfterPulledBack() then + if self:getCombineStrategy():isWaitingForUnloadAfterPulledBack() then -- combine pulled back so it's pipe is now out of the fruit. In this case, if the unloader is in front -- of the combine, it sometimes finds a path between the combine and the fruit to the pipe, we are trying to -- fix it here: the target is behind the combine, not under the pipe. When we get there, we may need another -- (short) pathfinding to get under the pipe. zOffset = -self:getCombinesMeasuredBackDistance() - 10 - elseif self.combineToUnload:getCpDriveStrategy():hasAutoAimPipe() then + elseif self:getCombineStrategy():hasAutoAimPipe() then if math.abs(self:getAutoAimPipeOffsetX()) < 3 then -- will drive behind the harvester, so target must be further back, making sure there's a few meters -- between the harvester's back and the tractor's front @@ -1722,12 +1816,13 @@ end function AIDriveStrategyUnloadCombine:getOffFieldPenalty(combineToUnload) local offFieldPenalty = PathfinderContext.defaultOffFieldPenalty if combineToUnload then - if combineToUnload:getCpDriveStrategy():isOnHeadland(1) then + local strategy = self:getCombineStrategy() + if strategy and strategy:isOnHeadland(1) then -- when the combine is on the first headland, chances are that we have to drive off-field to it, -- so make the life easier for the pathfinder offFieldPenalty = PathfinderContext.defaultOffFieldPenalty / 5 self:debug('Combine is on first headland, reducing off-field penalty for pathfinder to %.1f', offFieldPenalty) - elseif combineToUnload:getCpDriveStrategy():isOnHeadland(2) then + elseif strategy and strategy:isOnHeadland(2) then -- reduce less when on the second headland, there's more chance we'll be able to get to the combine -- on the headland offFieldPenalty = PathfinderContext.defaultOffFieldPenalty / 3 @@ -1796,7 +1891,7 @@ function AIDriveStrategyUnloadCombine:updateCombineStatus() end -- add hysteresis to reversing info from combine, isReversing() may temporarily return false during reversing, make sure we need -- multiple update loops to change direction - local combineToUnloadReversing = self.combineToUnloadReversing + (self.combineToUnload:getCpDriveStrategy():isReversing() and 0.1 or -0.1) + local combineToUnloadReversing = self.combineToUnloadReversing + (self:getCombineStrategy():isReversing() and 0.1 or -0.1) if self.combineToUnloadReversing < 0 and combineToUnloadReversing >= 0 then -- direction changed self.combineToUnloadReversing = 1 @@ -1824,10 +1919,10 @@ function AIDriveStrategyUnloadCombine:changeToUnloadWhenTrailerFull() else self:debug('trailer full, changing to unload course.') end - if self.combineToUnload:getCpDriveStrategy():isTurning() or - self.combineToUnload:getCpDriveStrategy():isAboutToTurn() then + if self:getCombineStrategy():isTurning() or + self:getCombineStrategy():isAboutToTurn() then self:debug('... but we are too close to the end of the row, or combine is turning, moving back before changing to unload course') - elseif self.combineToUnload and self.combineToUnload:getCpDriveStrategy():isAboutToReturnFromPocket() then + elseif self.combineToUnload and self:getCombineStrategy():isAboutToReturnFromPocket() then self:debug('... letting the combine return from the pocket') else self:debug('... moving back a little in case AD wants to take over') @@ -1858,7 +1953,7 @@ end --- we probably rather not approach the area around the turn so we are not in the way --- of the combine while it is turning. function AIDriveStrategyUnloadCombine:checkForCombineTurnArea() - local turnAreaCenterWp, r = self.combineToUnload:getCpDriveStrategy():getTurnArea() + local turnAreaCenterWp, r = self:getCombineStrategy():getTurnArea() if turnAreaCenterWp and turnAreaCenterWp:getDistanceFromVehicle(self.vehicle) <= r then self:debugSparse('Waiting for combine to pass the turn at %.1f, %.1f (r = %.1f) before the rendezvous waypoint', turnAreaCenterWp.x, turnAreaCenterWp.z, r) @@ -1875,7 +1970,50 @@ function AIDriveStrategyUnloadCombine:driveToCombine() self:setFieldSpeed() - self.combineToUnload:getCpDriveStrategy():reconfirmRendezvous() + self:getCombineStrategy():reconfirmRendezvous() + + -- If the combine has moved significantly since the current approach course was generated, + -- re-pathfind to its new position so we don't drive to an empty spot. + -- Guards keep this infrequent and fast: + -- • only check every 5 s (cheap distance math) + -- • skip if remaining course < 20 m (almost there — let proximity handling finish) + -- • skip if already within 25 m of the combine (driveBesideCombine takes over soon) + -- • only trigger when the combine has actually moved ≥ 30 m (genuine relocation) + -- Uses a capped iteration count (defaultMaxIterations) so the A* search completes in + -- well under a second on any field size, minimising the WAITING_FOR_PATHFINDER stop. + if g_time - (self.lastCombinePositionCheckTime or 0) > 5000 then + self.lastCombinePositionCheckTime = g_time + local remainingDist = self.course:getDistanceToLastWaypoint(self.course:getCurrentWaypointIx()) + local distToCombine = self:getDistanceFromCombine() + if remainingDist > 20 and distToCombine > 25 then + local cX, _, cZ = getWorldTranslation(self:getPipeOffsetReferenceNode()) + local lastX = self.combinePositionAtApproachStart and self.combinePositionAtApproachStart.x or cX + local lastZ = self.combinePositionAtApproachStart and self.combinePositionAtApproachStart.z or cZ + local moved = MathUtil.vector2Length(cX - lastX, cZ - lastZ) + if moved >= 30 then + local xOffset, zOffset = self:getPipeOffset(self.combineToUnload) + zOffset = -self:getCombinesMeasuredBackDistance() - 3 + self:debug('driveToCombine: combine moved %.1f m, re-pathfinding to new position', moved) + self.combinePositionAtApproachStart = { x = cX, z = cZ } + -- Fast re-path: cap iterations at the default (avoids multi-second searches on + -- large fields) and ignore the combine as an obstacle so the path finds the gap + -- behind it rather than routing around the vehicle body. + local context = PathfinderContext(self.vehicle) + context:maxFruitPercent(self:getMaxFruitPercent(self:getPipeOffsetReferenceNode(), xOffset, zOffset)) + context:offFieldPenalty(self:getOffFieldPenalty(self.combineToUnload)) + context:useFieldNum(CpFieldUtil.getFieldNumUnderVehicle(self.combineToUnload)) + context:areaToAvoid(self:getCombineStrategy():getAreaToAvoid()) + context:vehiclesToIgnore({ self.combineToUnload }) + context:maxIterations(HybridAStar.defaultMaxIterations) + self.pathfinderController:registerListeners(self, self.onPathfindingDoneToWaitingCombine, + self.onPathfindingFailedToMovingTarget, self.onPathfindingObstacleAtStart) + self.pathfinderController:findPathToNode(context, self:getPipeOffsetReferenceNode(), + xOffset or 0, zOffset or 0, 3) + self:setNewState(self.states.WAITING_FOR_PATHFINDER) + return + end + end + end -- towards the end of the course we start checking if we can already switch to unload if self.course:getDistanceToLastWaypoint(self.course:getCurrentWaypointIx()) < 15 and @@ -1896,26 +2034,26 @@ function AIDriveStrategyUnloadCombine:driveToMovingCombine() self:checkForCombineTurnArea() -- stop when too close to a combine not ready to unload (wait until it is done with turning for example) - if self:isWithinSafeManeuveringDistance(self.combineToUnload) and self.combineToUnload:getCpDriveStrategy():isManeuvering() then + if self:isWithinSafeManeuveringDistance(self.combineToUnload) and self:getCombineStrategy():isManeuvering() then self:startWaitingForManeuveringCombine() elseif self:isOkToStartUnloadingCombine() then self:startUnloadingCombine() end - if self.combineToUnload:getCpDriveStrategy():isWaitingForUnload() then + if self:getCombineStrategy():isWaitingForUnload() then self:debug('combine is now stopped and waiting for unload, wait for it to call again') self:startWaitingForSomethingToDo() return end if self.course:isCloseToLastWaypoint(AIDriveStrategyUnloadCombine.driveToCombineCourseExtensionLength / 2) and - self.combineToUnload:getCpDriveStrategy():hasRendezvousWith(self.vehicle) then + self:getCombineStrategy():hasRendezvousWith(self.vehicle) then self:debugSparse('Combine is late, waiting ...') self:setMaxSpeed(0) -- stop confirming the rendezvous, allow the combine to time out if it can't get here on time else -- yes honey, I'm on my way! - self.combineToUnload:getCpDriveStrategy():reconfirmRendezvous() + self:getCombineStrategy():reconfirmRendezvous() end end @@ -1933,7 +2071,7 @@ function AIDriveStrategyUnloadCombine:startWaitingForManeuveringCombine() end function AIDriveStrategyUnloadCombine:waitForManeuveringCombine() - if self:isWithinSafeManeuveringDistance(self.combineToUnload) and self.combineToUnload:getCpDriveStrategy():isManeuvering() then + if self:isWithinSafeManeuveringDistance(self.combineToUnload) and self:getCombineStrategy():isManeuvering() then self:setMaxSpeed(0) else self:debug('Combine stopped maneuvering') @@ -1959,7 +2097,7 @@ function AIDriveStrategyUnloadCombine:unloadStoppedCombine() return end local gx, gz - local combineDriver = self.combineToUnload:getCpDriveStrategy() + local combineDriver = self:getCombineStrategy() if combineDriver:isUnloadFinished() then if combineDriver:isWaitingForUnloadAfterCourseEnded() then if combineDriver:getFillLevelPercentage() < 0.1 then @@ -2002,9 +2140,19 @@ function AIDriveStrategyUnloadCombine:unloadMovingCombine() return end - local combineStrategy = self.combineToUnload:getCpDriveStrategy() + local combineStrategy = self:getCombineStrategy() local gx, gz = self:driveBesideCombine() + -- For manually-driven combines the strategy IS a CpManualCombineProxy. + -- The farmer is in full control: ignore fill level, alignment, turning state, etc. + -- Stay under the pipe until the proxy's isUnloadFinished() fires (pipe closed for 2s) + -- or the grain cart's own trailer fills up (handled by changeToUnloadWhenTrailerFull above). + if combineStrategy.isManualProxy then + self:debugSparse('unloadMovingCombine (manual): isDischarging=%s', + tostring(combineStrategy:isDischarging())) + return gx, gz + end + --when the combine is empty, stop and wait for next combine (unless this can't work without an unloader nearby) if combineStrategy:getFillLevelPercentage() <= 0.1 and not combineStrategy:alwaysNeedsUnloader() then self:debug('Combine empty, finish unloading.') @@ -2061,7 +2209,6 @@ function AIDriveStrategyUnloadCombine:unloadMovingCombine() self:isBehindAndAlignedToCombine(true) self:isInFrontAndAlignedToMovingCombine(true) self:info('not in a good position to unload, cancelling rendezvous, trying to recover') - -- for some reason (like combine turned) we are not in a good position anymore then set us up again self:startWaitingForSomethingToDo() end return gx, gz @@ -2170,12 +2317,15 @@ function AIDriveStrategyUnloadCombine:onBlockingVehicle(blockingVehicle, isBack) -- TODO: maybe a generic getTrailer() ? local referenceObject = AIUtil.getImplementOrVehicleWithSpecialization(self.vehicle, Trailer) or AIUtil.getImplementOrVehicleWithSpecialization(self.vehicle, HookLiftTrailer) or self.vehicle - if AIDriveStrategyCombineCourse.isActiveCpCombine(blockingVehicle) then + local isManualBlocker = blockingVehicle.cpIsManualCombineCallingUnloader and blockingVehicle:cpIsManualCombineCallingUnloader() + if AIDriveStrategyCombineCourse.isActiveCpCombine(blockingVehicle) or isManualBlocker then -- except we are blocking our buddy, so set up a course parallel to the combine's direction, -- with an offset from the combine that makes sure we are clear. Use the trailer's root node (and not -- the tractor's) as when we reversing, it is easier when the trailer remains on the same side of the combine local dx, _, _ = localToLocal(referenceObject.rootNode, blockingVehicle:getAIDirectionNode(), 0, 0, 0) - local xOffset = self.vehicle.size.width / 2 + blockingVehicle:getCpDriveStrategy():getWorkWidth() / 2 + 2 + local blockingStrategy = blockingVehicle:getCpDriveStrategy() or (blockingVehicle.cpGetManualCombineProxy and blockingVehicle:cpGetManualCombineProxy()) + local blockingWorkWidth = blockingStrategy:getWorkWidth() + local xOffset = self.vehicle.size.width / 2 + blockingWorkWidth / 2 + 2 xOffset = dx > 0 and xOffset or -xOffset self:setNewState(self.states.MOVING_AWAY_FROM_OTHER_VEHICLE) self.state.properties.vehicle = blockingVehicle @@ -2368,7 +2518,7 @@ end function AIDriveStrategyUnloadCombine:makeRoomForCombineTurningOnHeadland() local dProximity, _ = self.proximityController:checkBlockingVehicleFront() local d, _, dz = self:getDistanceFromCombine(self.combineToUnload) - local dLimit = 0.6 * self.combineToUnload:getCpDriveStrategy():getWorkWidth() + local dLimit = 0.6 * self:getCombineStrategy():getWorkWidth() -- if we are already behind the harvester's back and far enough and not blocking it and -- not in our proximity, then stop if dz > 0 and d > dLimit and dProximity > dLimit then diff --git a/scripts/events/CpManualUnloaderEvent.lua b/scripts/events/CpManualUnloaderEvent.lua new file mode 100644 index 000000000..8222d43ca --- /dev/null +++ b/scripts/events/CpManualUnloaderEvent.lua @@ -0,0 +1,42 @@ +---@class CpManualUnloaderEvent +CpManualUnloaderEvent = {} +local CpManualUnloaderEvent_mt = Class(CpManualUnloaderEvent, Event) + +InitEventClass(CpManualUnloaderEvent, "CpManualUnloaderEvent") + +function CpManualUnloaderEvent.emptyNew() + local self = Event.new(CpManualUnloaderEvent_mt) + return self +end + +function CpManualUnloaderEvent.new(vehicle) + local self = CpManualUnloaderEvent.emptyNew() + self.vehicle = vehicle + return self +end + +function CpManualUnloaderEvent:readStream(streamId, connection) + self.vehicle = NetworkUtil.readNodeObject(streamId) + self:run(connection) +end + +function CpManualUnloaderEvent:writeStream(streamId, connection) + NetworkUtil.writeNodeObject(streamId, self.vehicle) +end + +function CpManualUnloaderEvent:run(connection) + if self.vehicle and self.vehicle.cpToggleManualUnloader then + self.vehicle:cpToggleManualUnloader() + end + if not connection:getIsServer() then + g_server:broadcastEvent(CpManualUnloaderEvent.new(self.vehicle), nil, connection, self.vehicle) + end +end + +function CpManualUnloaderEvent.sendEvent(vehicle) + if g_server ~= nil then + g_server:broadcastEvent(CpManualUnloaderEvent.new(vehicle), nil, nil, vehicle) + else + g_client:getServerConnection():sendEvent(CpManualUnloaderEvent.new(vehicle)) + end +end diff --git a/scripts/gui/hud/CpFieldworkHudPage.lua b/scripts/gui/hud/CpFieldworkHudPage.lua index 21f27c862..2880d7aa7 100644 --- a/scripts/gui/hud/CpFieldworkHudPage.lua +++ b/scripts/gui/hud/CpFieldworkHudPage.lua @@ -78,7 +78,23 @@ function CpFieldWorkHudPageElement:setupElements(baseHud, vehicle, lines, wMargi baseHud:openCourseManagerGui(vehicle) end, vehicle) - CpGuiUtil.addCopyCourseBtn(self, baseHud, vehicle, lines, wMargin, hMargin, 1) + CpGuiUtil.addCopyCourseBtn(self, baseHud, vehicle, lines, wMargin, hMargin, 1) + + --- Call Unloader toggle button (left side) + self.callManualUnloaderBtn = baseHud:addLeftLineTextButton(self, 6, CpBaseHud.defaultFontSize, + function(vehicle) + if vehicle.cpToggleManualUnloader then + vehicle:cpToggleManualUnloader() + end + end, vehicle) + + --- Call Unloader status text (right side) + self.callManualUnloaderStatus = baseHud:addRightLineTextButton(self, 6, CpBaseHud.defaultFontSize, + function(vehicle) + if vehicle.cpToggleManualUnloader then + vehicle:cpToggleManualUnloader() + end + end, vehicle) end function CpFieldWorkHudPageElement:update(dt) @@ -130,4 +146,26 @@ function CpFieldWorkHudPageElement:updateContent(vehicle, status) end CpGuiUtil.updateCopyBtn(self, vehicle, status) + + if self.callManualUnloaderBtn then + local hasPipe = vehicle.spec_pipe ~= nil or AIUtil.hasChildVehicleWithSpecialization(vehicle, Pipe) + local isCpActive = vehicle:getIsCpActive() + local isCallActive = vehicle.cpIsManualCombineCallingUnloader and vehicle:cpIsManualCombineCallingUnloader() + -- Forage harvesters have a rotatable auto-aim spout and are not supported — hide the button entirely. + local showBtn = hasPipe and not isCpActive and not ImplementUtil.isChopper(vehicle) + self.callManualUnloaderBtn:setVisible(showBtn) + self.callManualUnloaderStatus:setVisible(showBtn) + if showBtn then + self.callManualUnloaderBtn:setTextDetails(g_i18n:getText("CP_callManualUnloader")) + if isCallActive then + self.callManualUnloaderBtn:setColor(unpack(CpBaseHud.ON_COLOR)) + self.callManualUnloaderStatus:setTextDetails(g_i18n:getText("CP_callManualUnloaderActive")) + self.callManualUnloaderStatus:setColor(unpack(CpBaseHud.ON_COLOR)) + else + self.callManualUnloaderBtn:setColor(unpack(CpBaseHud.OFF_COLOR)) + self.callManualUnloaderStatus:setTextDetails(g_i18n:getText("CP_callManualUnloaderInactive")) + self.callManualUnloaderStatus:setColor(unpack(CpBaseHud.OFF_COLOR)) + end + end + end end diff --git a/scripts/specializations/CpAIFieldWorker.lua b/scripts/specializations/CpAIFieldWorker.lua index 0206324c7..0e8414414 100644 --- a/scripts/specializations/CpAIFieldWorker.lua +++ b/scripts/specializations/CpAIFieldWorker.lua @@ -41,6 +41,8 @@ end function CpAIFieldWorker.registerEventListeners(vehicleType) SpecializationUtil.registerEventListener(vehicleType, "onLoad", CpAIFieldWorker) SpecializationUtil.registerEventListener(vehicleType, "onLoadFinished", CpAIFieldWorker) + SpecializationUtil.registerEventListener(vehicleType, "onUpdate", CpAIFieldWorker) + SpecializationUtil.registerEventListener(vehicleType, "onDelete", CpAIFieldWorker) SpecializationUtil.registerEventListener(vehicleType, "onCpEmpty", CpAIFieldWorker) SpecializationUtil.registerEventListener(vehicleType, "onCpFull", CpAIFieldWorker) @@ -70,6 +72,10 @@ function CpAIFieldWorker.registerFunctions(vehicleType) SpecializationUtil.registerFunction(vehicleType, "startCpAtLastWp", CpAIFieldWorker.startCpAtLastWp) SpecializationUtil.registerFunction(vehicleType, "getCpStartingPointSetting", CpAIFieldWorker.getCpStartingPointSetting) SpecializationUtil.registerFunction(vehicleType, "getCpLaneOffsetSetting", CpAIFieldWorker.getCpLaneOffsetSetting) + + SpecializationUtil.registerFunction(vehicleType, "cpToggleManualUnloader", CpAIFieldWorker.cpToggleManualUnloader) + SpecializationUtil.registerFunction(vehicleType, "cpIsManualCombineCallingUnloader", CpAIFieldWorker.cpIsManualCombineCallingUnloader) + SpecializationUtil.registerFunction(vehicleType, "cpGetManualCombineProxy", CpAIFieldWorker.cpGetManualCombineProxy) end function CpAIFieldWorker.registerOverwrittenFunctions(vehicleType) @@ -260,6 +266,63 @@ function CpAIFieldWorker:onCpFinished() end +------------------------------------------------------------------------------------------------------------------------ +--- Manual combine "Call Unloader" proxy management +------------------------------------------------------------------------------------------------------------------------ + +function CpAIFieldWorker:onUpdate(dt) + local spec = CpAIFieldWorker.getSpec(self) + if spec and spec.cpManualCombineProxy then + -- If the player handed the combine over to CP (e.g. activated autopilot mid-session), + -- deactivate the manual unloader proxy so it doesn't conflict with the CP-driven combine. + if self:getIsCpActive() then + self:cpToggleManualUnloader() + else + spec.cpManualCombineProxy:update(dt) + end + end +end + +function CpAIFieldWorker:onDelete() + local spec = CpAIFieldWorker.getSpec(self) + if spec and spec.cpManualCombineProxy then + spec.cpManualCombineProxy:delete() + spec.cpManualCombineProxy = nil + end +end + +function CpAIFieldWorker:cpToggleManualUnloader() + local spec = CpAIFieldWorker.getSpec(self) + if not spec then return end + if spec.cpManualCombineProxy then + CpUtil.debugVehicle(CpDebug.DBG_FIELDWORK, self, 'manual unloader deactivated') + spec.cpManualCombineProxy:delete() + spec.cpManualCombineProxy = nil + else + if self:getIsCpActive() then + CpUtil.debugVehicle(CpDebug.DBG_FIELDWORK, self, 'Cannot call manual unloader while CP is active') + return + end + CpUtil.debugVehicle(CpDebug.DBG_FIELDWORK, self, 'manual unloader activated') + spec.cpManualCombineProxy = CpManualCombineProxy(self) + end + if not self.isServer then + CpManualUnloaderEvent.sendEvent(self) + end +end + +function CpAIFieldWorker:cpIsManualCombineCallingUnloader() + local spec = CpAIFieldWorker.getSpec(self) + return spec and spec.cpManualCombineProxy ~= nil +end + +function CpAIFieldWorker:cpGetManualCombineProxy() + local spec = CpAIFieldWorker.getSpec(self) + return spec and spec.cpManualCombineProxy +end + +------------------------------------------------------------------------------------------------------------------------ + function CpAIFieldWorker:getCanStartCpFieldWork() self:updateAIFieldWorkerImplementData() -- built in helper can't handle it, but we may be able to ... diff --git a/scripts/specializations/CpAIWorker.lua b/scripts/specializations/CpAIWorker.lua index 75655dc28..c2ad86271 100644 --- a/scripts/specializations/CpAIWorker.lua +++ b/scripts/specializations/CpAIWorker.lua @@ -180,6 +180,12 @@ function CpAIWorker:onRegisterActionEvents(isActiveForInput, isActiveForInputIgn CpGuiUtil.openCourseManagerGui(self) end, g_i18n:getText("input_CP_OPEN_COURSEMANAGER")) + addActionEvent(self, InputAction.CP_CALL_GRAIN_CART, function () + if self.cpToggleManualUnloader then + self:cpToggleManualUnloader() + end + end) + CpAIWorker.updateActionEvents(self) end end @@ -245,6 +251,21 @@ function CpAIWorker:updateActionEvents() actionEvent = spec.actionEvents[InputAction.CP_GENERATE_COURSE] g_inputBinding:setActionEventActive(actionEvent.actionEventId, self:getCanStartCpFieldWork()) + + actionEvent = spec.actionEvents[InputAction.CP_CALL_GRAIN_CART] + if actionEvent then + local hasPipe = self.spec_pipe ~= nil or AIUtil.hasChildVehicleWithSpecialization(self, Pipe) + local isCpActive = self:getIsCpActive() + -- Forage harvesters (auto-aim rotatable spout) are not supported — hide the keybind. + local showCallManualUnloader = hasPipe and not isCpActive and not ImplementUtil.isChopper(self) + g_inputBinding:setActionEventActive(actionEvent.actionEventId, showCallManualUnloader) + if showCallManualUnloader then + local isActive = self.cpIsManualCombineCallingUnloader and self:cpIsManualCombineCallingUnloader() + local status = isActive and g_i18n:getText("CP_callManualUnloaderActive") or g_i18n:getText("CP_callManualUnloaderInactive") + g_inputBinding:setActionEventText(actionEvent.actionEventId, + string.format("%s (%s)", g_i18n:getText("CP_callManualUnloader"), status)) + end + end end end diff --git a/translations/translation_en.xml b/translations/translation_en.xml index 29744c145..f26f40018 100644 --- a/translations/translation_en.xml +++ b/translations/translation_en.xml @@ -13,6 +13,9 @@ + + + @@ -1103,5 +1106,6 @@ Now your selection should look similar to the image. +