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..6047bc40d 100644 --- a/scripts/ai/CollisionAvoidanceController.lua +++ b/scripts/ai/CollisionAvoidanceController.lua @@ -61,25 +61,28 @@ function CollisionAvoidanceController:findPotentialCollisions() if AIDriveStrategyCombineCourse.isActiveCpCombine(vehicle) then local d = calcDistanceFrom(self.vehicle.rootNode, vehicle.rootNode) if d < self.range then - local myCourse = self.strategy:getCurrentCourse() - local otherCourse = vehicle:getCpDriveStrategy():getCurrentCourse() - local myDistanceToCollision, otherDistanceToCollision = myCourse:intersects(otherCourse, self.lookahead, true) - if myDistanceToCollision then - -- our course intersects with this vehicle's course (lastSpeedReal is in m/ms) - -- for our own ETE, we always use the field speed and not the actual speed. This is to make sure - -- we come to a full stop on a warning and remain stopped while the warning is active - local myEte = myDistanceToCollision / (self.strategy:getFieldSpeed()) - local otherEte = CpMathUtil.divide(otherDistanceToCollision, (vehicle.lastSpeedReal * 1000)) - -- self:debug('Checking %s at %.1f m, %.1f, ETE %.1f %.1f', CpUtil.getName(vehicle), d, myDistanceToCollision, myEte, otherEte) - if math.abs(myEte - otherEte) < self.eteDiffThreshold then - if not self.warning:get() or (self.warning:get() and vehicle ~= self.warningVehicle) then - -- no warning is active yet, or there is, but this is a different vehicle - self:debug('collision warning: my course intersects with %s in %.1f m, my ETE %.1f, other ETE %.1f', - CpUtil.getName(vehicle), myDistanceToCollision, myEte, otherEte) + local otherStrategy = vehicle:getCpDriveStrategy() + if otherStrategy then + local myCourse = self.strategy:getCurrentCourse() + local otherCourse = otherStrategy:getCurrentCourse() + local myDistanceToCollision, otherDistanceToCollision = myCourse:intersects(otherCourse, self.lookahead, true) + if myDistanceToCollision then + -- our course intersects with this vehicle's course (lastSpeedReal is in m/ms) + -- for our own ETE, we always use the field speed and not the actual speed. This is to make sure + -- we come to a full stop on a warning and remain stopped while the warning is active + local myEte = myDistanceToCollision / (self.strategy:getFieldSpeed()) + local otherEte = CpMathUtil.divide(otherDistanceToCollision, (vehicle.lastSpeedReal * 1000)) + -- self:debug('Checking %s at %.1f m, %.1f, ETE %.1f %.1f', CpUtil.getName(vehicle), d, myDistanceToCollision, myEte, otherEte) + if math.abs(myEte - otherEte) < self.eteDiffThreshold then + if not self.warning:get() or (self.warning:get() and vehicle ~= self.warningVehicle) then + -- no warning is active yet, or there is, but this is a different vehicle + self:debug('collision warning: my course intersects with %s in %.1f m, my ETE %.1f, other ETE %.1f', + CpUtil.getName(vehicle), myDistanceToCollision, myEte, otherEte) + end + self.warningVehicle = vehicle + self.warning:set(true, self.clearWarningDelayMs) + return end - self.warningVehicle = vehicle - self.warning:set(true, self.clearWarningDelayMs) - return end end end 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/strategies/AIDriveStrategyUnloadCombine.lua b/scripts/ai/strategies/AIDriveStrategyUnloadCombine.lua index eecc77c80..dfa0c466f 100644 --- a/scripts/ai/strategies/AIDriveStrategyUnloadCombine.lua +++ b/scripts/ai/strategies/AIDriveStrategyUnloadCombine.lua @@ -318,12 +318,53 @@ 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 + -- Try the CP drive strategy directly without requiring getIsCpActive() to be true. + -- This prevents releasing the combine during brief CP state transitions (e.g. headland turns) + -- where the strategy object is still valid but getIsCpActive() momentarily returns false. + if self.combineToUnload.getCpDriveStrategy then + local strategy = self.combineToUnload:getCpDriveStrategy() + if strategy then return strategy end + end + -- Fall back to the manual combine proxy + if self.combineToUnload.cpGetManualCombineProxy then + return self.combineToUnload:cpGetManualCombineProxy() + 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) @@ -359,8 +400,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 +414,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 +450,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 +487,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 +528,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 +585,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 +613,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!", @@ -632,7 +674,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 +695,28 @@ 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. + -- self.ppc:getLookaheadDistance() can be inflated up to 2x the base value when the cart + -- is far from the stale placeholder course. A 12 m lookahead puts the goal point too far + -- ahead for responsive curve following — a gentle S-curve doesn't register as a heading + -- change until the cart has already overshot. normalLookAheadDistance is constant (≈5-6m) + -- and is 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.normalLookAheadDistance or 6) + 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 +732,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 +762,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 +851,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 +927,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 +946,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 +986,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 +1011,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 +1144,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 +1161,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 +1172,7 @@ function AIDriveStrategyUnloadCombine:getAutoAimPipeOffsetX() end function AIDriveStrategyUnloadCombine:getCombinesMeasuredBackDistance() - return self.combineToUnload:getCpDriveStrategy():getMeasuredBackDistance() + return self:getCombineStrategy():getMeasuredBackDistance() end function AIDriveStrategyUnloadCombine:getAllTrailersFull(fullThresholdPercentage) @@ -1137,8 +1203,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 +1298,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 +1346,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 +1365,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 +1428,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 @@ -1389,7 +1455,7 @@ end ---@return number approximate waypoint index of the combine's current position function AIDriveStrategyUnloadCombine:setupFollowCourse() ---@type Course - self.combineCourse = self.combineToUnload:getCpDriveStrategy():getFieldworkCourse() + self.combineCourse = self:getCombineStrategy():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 +1463,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 = self:getCombineStrategy():getClosestFieldworkWaypointIx() or self.combineCourse:getCurrentWaypointIx() return followCourse, followCourseIx end @@ -1412,6 +1478,32 @@ function AIDriveStrategyUnloadCombine:startCourseFollowingCombine() self.followingCourseOffset = self:getFollowingCourseOffset(self.combineToUnload) self.followCourse:setOffset(self.followingCourseOffset, 0) self.reverseForTurnCourse = nil + -- Initialise the refresh timer so the first periodic refresh fires 5 s from now, + -- not on the very first update frame. + self.followCourseRefreshTime = g_time + + -- For manual combines build a minimal placeholder course. The PPC needs SOME course to + -- initialise against, but we never use it for steering: driveBesideCombine() returns a + -- live goal point directly from the pipe reference node on every frame, bypassing the + -- course entirely. The placeholder is a straight 100 m course starting at the combine's + -- current position and pointing in the combine's current heading. + local combineStrategy = self:getCombineStrategy() + if combineStrategy and combineStrategy.isManualProxy and combineStrategy:isManualProxy() then + local combineX, _, combineZ = getWorldTranslation(self.combineToUnload:getAIDirectionNode()) + local forwardX, _, forwardZ = localToWorld(self.combineToUnload:getAIDirectionNode(), 0, 0, 100) + local placeholder = Course.createFromTwoWorldPositions( + self.vehicle, + combineX, combineZ, + forwardX, forwardZ, + 0, 0, 0, 10, false) + if placeholder then + self.followCourse = placeholder + self.followCourse:setOffset(self.followingCourseOffset, 0) + startIx = 1 + self:debug('Manual combine: placeholder follow course (PPC unused; direct steering via driveBesideCombine)') + end + end + self:debug('Will follow combine\'s course at waypoint %d, side offset %.1f', startIx, self.followCourse.offsetX) self:startCourse(self.followCourse, startIx) self:setNewState(self.states.UNLOADING_MOVING_COMBINE) @@ -1422,7 +1514,7 @@ function AIDriveStrategyUnloadCombine:getCombineToUnload() end function AIDriveStrategyUnloadCombine:getPipeOffsetReferenceNode() - return self.combineToUnload:getCpDriveStrategy():getPipeOffsetReferenceNode() + return self:getCombineStrategy():getPipeOffsetReferenceNode() end ------------------------------------------------------------------------------------------------------------------------ @@ -1467,16 +1559,40 @@ end ------------------------------------------------------------------------------------------------------------------------ -- Pathfinding to waiting (not moving) combine ------------------------------------------------------------------------------------------------------------------------ -function AIDriveStrategyUnloadCombine:startPathfindingToWaitingCombine(xOffset, zOffset) +--- @param failureCallback function|nil optional override for pathfinding failure; defaults to +--- onPathfindingFailedToStationaryTarget (which stops the job). Pass onPathfindingFailedToMovingTarget +--- for moving or manual combines so a failure only causes a wait-and-retry instead of killing the job. +--- @param isManualCombine boolean|nil when true, tunes the pathfinder for manual-combine approach: +--- - ignores the combine vehicle as an obstacle (prevents routing around it) +--- - uses a relaxed fruit threshold (25% vs 10%) so the already-harvested approach strip +--- doesn't cause excessive penalty and the search terminates much faster +--- - caps maxIterations at the default 40 000 instead of the potentially huge +--- field-polygon-scaled value, avoiding multi-second searches on large maps +function AIDriveStrategyUnloadCombine:startPathfindingToWaitingCombine(xOffset, zOffset, failureCallback, isManualCombine) local context = PathfinderContext(self.vehicle) - local maxFruitPercent = self:getMaxFruitPercent(self:getPipeOffsetReferenceNode(), xOffset, zOffset) + local maxFruitPercent, vehiclesToIgnore, maxIterations + if isManualCombine then + -- The combine has just harvested the target strip so the approach path has low fruit. + -- A relaxed threshold finds a path in far fewer iterations and still keeps the grain + -- cart out of heavy standing crop. + maxFruitPercent = 25 + -- Exclude the combine itself so the pathfinder routes to the gap behind it rather + -- than treating the combine body as a solid obstacle to go around. + vehiclesToIgnore = { self.combineToUnload } + -- Cap at the default to prevent searches that can run for tens of seconds on large fields. + maxIterations = HybridAStar.defaultMaxIterations + else + maxFruitPercent = self:getMaxFruitPercent(self:getPipeOffsetReferenceNode(), xOffset, zOffset) + vehiclesToIgnore = {} + maxIterations = PathfinderUtil.getMaxIterationsForFieldPolygon(self.vehicle:cpGetFieldPolygon()) + end context:maxFruitPercent(maxFruitPercent) context:offFieldPenalty(self:getOffFieldPenalty(self.combineToUnload)) context:useFieldNum(CpFieldUtil.getFieldNumUnderVehicle(self.combineToUnload)) - context:areaToAvoid(self.combineToUnload:getCpDriveStrategy():getAreaToAvoid()) - context:vehiclesToIgnore({}):maxIterations(PathfinderUtil.getMaxIterationsForFieldPolygon(self.vehicle:cpGetFieldPolygon())) + context:areaToAvoid(self:getCombineStrategy():getAreaToAvoid()) + context:vehiclesToIgnore(vehiclesToIgnore):maxIterations(maxIterations) 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 +1601,11 @@ function AIDriveStrategyUnloadCombine:onPathfindingDoneToWaitingCombine(controll self:debug('Pathfinding to waiting combine successful') course:adjustForReversing(math.max(1, -AIUtil.getDirectionNodeToReverserNodeOffset(self.vehicle))) self:startCourse(course, 1) + -- Initialise the approach redirect timer and clear the last target so the first + -- redirect check always compares against the current pipe position. + self.lastApproachRedirectTime = g_time + self.lastApproachRedirectX = nil + self.lastApproachRedirectZ = nil self:setNewState(self.states.DRIVING_TO_COMBINE) return true else @@ -1624,12 +1745,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 +1767,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 @@ -1672,7 +1793,12 @@ function AIDriveStrategyUnloadCombine:call(combine, waypoint) self:startUnloadingCombine() elseif self:isPathfindingNeeded(self.vehicle, self:getPipeOffsetReferenceNode(), xOffset, zOffset) then self:setNewState(self.states.WAITING_FOR_PATHFINDER) - self:startPathfindingToWaitingCombine(xOffset, zOffset) + -- For manually-driven combines, tune pathfinding for faster, safer approach. + local isManualCombine = self.combineToUnload.cpIsManualCombineCallingUnloader and + self.combineToUnload:cpIsManualCombineCallingUnloader() + self:startPathfindingToWaitingCombine(xOffset, zOffset, + isManualCombine and self.onPathfindingFailedToMovingTarget or nil, + isManualCombine) else self:debug('Can\'t start pathfinding to waiting combine, and not in a good position to unload, too close?') self:startWaitingForSomethingToDo() @@ -1722,12 +1848,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 +1923,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 +1951,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 +1985,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 +2002,48 @@ function AIDriveStrategyUnloadCombine:driveToCombine() self:setFieldSpeed() - self.combineToUnload:getCpDriveStrategy():reconfirmRendezvous() + self:getCombineStrategy():reconfirmRendezvous() + + -- Every ~10 s, redirect toward the combine's live position without stopping, but ONLY if the + -- combine's pipe has moved more than 15 m from where we last aimed. This prevents jarring + -- course corrections when the combine is driving straight and the redirect is unnecessary. + if g_time - (self.lastApproachRedirectTime or g_time) > 10000 then + self.lastApproachRedirectTime = g_time + local d = self:getDistanceFromCombine() + -- Only redirect when the combine is far enough that a course update is meaningful; + -- close-in positioning is handled by isOkToStartUnloadingCombine() below. + if d > 20 then + -- Check whether the pipe has actually moved enough to warrant a new course. + local cX, cY, cZ = getWorldTranslation(self:getPipeOffsetReferenceNode()) + local lastX = self.lastApproachRedirectX or cX + local lastZ = self.lastApproachRedirectZ or cZ + local pipeMoved = MathUtil.vector2Length(cX - lastX, cZ - lastZ) + -- Straight-line redirect is only safe when the combine is roughly ahead of the grain + -- cart (within ~40°). If the combine is off to the side (e.g. it turned onto the next + -- row) a straight line would cut through standing crop. Skip the redirect in that case + -- and let the next A* call handle the reroute. + local lx, _, lz = worldToLocal(self.vehicle:getAIDirectionNode(), cX, cY, cZ) + local combineAhead = lz > 0 + local angleToCombieDeg = math.deg(math.abs(math.atan2(lx, math.max(lz, 0.001)))) + if combineAhead and angleToCombieDeg < 40 and pipeMoved > 15 then + self.lastApproachRedirectX = cX + self.lastApproachRedirectZ = cZ + local xOffset, zOffset = self:getPipeOffset(self.combineToUnload) + local zTarget = -self:getCombinesMeasuredBackDistance() - 3 + local redirectCourse = Course.createFromNodeToNode( + self.vehicle, + self.vehicle:getAIDirectionNode(), + self:getPipeOffsetReferenceNode(), + xOffset, 0, zTarget, 5, false) + if redirectCourse then + self:debug('driveToCombine: redirecting toward combine live position (d=%.1f m, angle=%.1f°, pipeMoved=%.1f m)', d, angleToCombieDeg, pipeMoved) + self:startCourse(redirectCourse, 1) + end + else + self:debug('driveToCombine: skipping redirect, combine is off-axis (ahead=%s, angle=%.1f°) or pipe has not moved enough (%.1f m)', tostring(combineAhead), angleToCombieDeg, pipeMoved) + 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 +2064,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 +2101,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 +2127,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 +2170,22 @@ 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). + -- The cart will drift from the static placeholder course as the combine curves — disable the + -- PPC off-track check for the duration (5000 ms >> any realistic frame interval). + if combineStrategy.isManualProxy then + self.ppc:disableStopWhenOffTrack(5000) + 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 +2242,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 +2350,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 +2551,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..d7b7625ab 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, 'Call grain cart deactivated') + spec.cpManualCombineProxy:delete() + spec.cpManualCombineProxy = nil + else + if self:getIsCpActive() then + CpUtil.debugVehicle(CpDebug.DBG_FIELDWORK, self, 'Cannot call grain cart while CP is active') + return + end + CpUtil.debugVehicle(CpDebug.DBG_FIELDWORK, self, 'Call grain cart 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. +