diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 012636f3d..767048df2 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -35,7 +35,7 @@ jobs: lua LoggerTest.lua - name: Run course generator unit tests run: | - cd scripts/courseGenerator/test + pushd scripts/courseGenerator/test lua BlockSequencerTest.lua lua CacheMapTest.lua lua CenterTest.lua @@ -53,3 +53,6 @@ jobs: lua TransformTest.lua lua VertexTest.lua lua WrapAroundIndexTest.lua + popd + pushd scripts/pathfinder/test + lua GraphPathfinderTest.lua \ No newline at end of file diff --git a/Courseplay.lua b/Courseplay.lua index a3e239124..931177478 100644 --- a/Courseplay.lua +++ b/Courseplay.lua @@ -32,6 +32,7 @@ function Courseplay:registerXmlSchema() CpBaseHud.registerXmlSchema(self.xmlSchema, self.xmlKey) CpHudInfoTexts.registerXmlSchema(self.xmlSchema, self.xmlKey) CpInGameMenu.registerXmlSchema(self.xmlSchema, self.xmlKey) + Graph.registerXmlSchema(self.xmlSchema, self.xmlKey) end --- Loads data not tied to a savegame. @@ -115,6 +116,7 @@ function Courseplay:loadMap(filename) self.xmlFile = XMLFile.load("cpXml", filePath , self.xmlSchema) if self.xmlFile == nil then return end self.globalSettings:loadFromXMLFile(self.xmlFile, g_Courseplay.xmlKey) + g_graph:loadFromXMLFile(self.xmlFile, g_Courseplay.xmlKey) self.xmlFile:delete() end @@ -128,15 +130,18 @@ function Courseplay:deleteMap() self:saveUserSettings() end g_courseEditor:delete() + g_graphEditor:delete() BufferedCourseDisplay.deleteBuffer() g_signPrototypes:delete() g_consoleCommands:delete() + g_graph:delete() end function Courseplay:setupGui() CpInGameMenu.setupGui(self.courseStorage) self.infoTextsHud = CpHudInfoTexts() - + TargetPointSelectionDialog.register() + FilltypeSelectionDialog.register() --- Adding Player input bindings local function addPlayerActionEvents(self, superFunc, ...) superFunc(self, ...) @@ -144,6 +149,11 @@ function Courseplay:setupGui() g_messageCenter:publishDelayed(MessageType.GUI_CP_INGAME_OPEN) end, false, true, false, true) g_inputBinding:setActionEventTextVisibility(id, false) + + local _, id = g_inputBinding:registerActionEvent(InputAction.CP_OPEN_GRAPH_EDITOR, self, function () + g_graphEditor:open() + end, false, true, false, true) + g_inputBinding:setActionEventTextVisibility(id, false) -- CpDebug.registerEvents() end PlayerInputComponent.registerGlobalPlayerActionEvents = Utils.overwrittenFunction( @@ -166,16 +176,19 @@ end --- Saves all global data, for example global settings. function Courseplay.saveToXMLFile(missionInfo) if missionInfo.isValid then - local saveGamePath = missionInfo.savegameDirectory .."/" - local xmlFile = XMLFile.create("cpXml", saveGamePath.. "Courseplay.xml", - "Courseplay", g_Courseplay.xmlSchema) - if xmlFile then - g_Courseplay.globalSettings:saveToXMLFile(xmlFile, g_Courseplay.xmlKey) - xmlFile:save() - xmlFile:delete() - end - g_Courseplay:saveUserSettings() - g_assignedCoursesManager:saveAssignedCourses(saveGamePath) + CpUtil.try(function () + local saveGamePath = missionInfo.savegameDirectory .."/" + local xmlFile = XMLFile.create("cpXml", saveGamePath.. "Courseplay.xml", + "Courseplay", g_Courseplay.xmlSchema) + if xmlFile then + g_Courseplay.globalSettings:saveToXMLFile(xmlFile, g_Courseplay.xmlKey) + g_graph:saveToXMLFile(xmlFile, g_Courseplay.xmlKey) + xmlFile:save() + xmlFile:delete() + end + g_Courseplay:saveUserSettings() + g_assignedCoursesManager:saveAssignedCourses(saveGamePath) + end) end end FSCareerMissionInfo.saveToXMLFile = Utils.prependedFunction(FSCareerMissionInfo.saveToXMLFile, Courseplay.saveToXMLFile) @@ -186,6 +199,8 @@ function Courseplay:update(dt) g_triggerManager:update(dt) g_baleToCollectManager:update(dt) g_courseEditor:update(dt) + g_graphEditor:update(dt) + g_graph:update(dt) if not self.postInit then -- Doubles the map zoom for 4x Maps. Mainly to make it easier to set targets for unload triggers. self.postInit = true @@ -206,6 +221,7 @@ function Courseplay:draw() g_triggerManager:draw() g_baleToCollectManager:draw() end + g_graph:draw() g_devHelper:draw() CpDebug:draw() if not g_gui:getIsGuiVisible() and not g_noHudModeEnabled then @@ -261,6 +277,7 @@ function Courseplay:load() --- Register additional AI messages. CpAIMessages.register() g_vineScanner:setup() + g_graph:setup() end --- Registers all cp specializations. @@ -280,6 +297,7 @@ function Courseplay.register(typeManager) CpAICombineUnloader.register(typeManager, typeName, typeEntry.specializations) CpAISiloLoaderWorker.register(typeManager, typeName, typeEntry.specializations) CpAIBunkerSiloWorker.register(typeManager, typeName, typeEntry.specializations) + CpAIStreetWorker.register(typeManager, typeName, typeEntry.specializations) -- TODO 25 CpGamePadHud.register(typeManager, typeName,typeEntry.specializations) CpHud.register(typeManager, typeName, typeEntry.specializations) CpInfoTexts.register(typeManager, typeName, typeEntry.specializations) diff --git a/config/GraphEditorCategories.xml b/config/GraphEditorCategories.xml new file mode 100644 index 000000000..bdcdb8c16 --- /dev/null +++ b/config/GraphEditorCategories.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/config/HudSettingsSetup.xml b/config/HudSettingsSetup.xml index db6293fd6..d3678b831 100644 --- a/config/HudSettingsSetup.xml +++ b/config/HudSettingsSetup.xml @@ -20,9 +20,7 @@ 5 - CP_job_fieldWork @@ -30,7 +28,7 @@ CP_job_bunkerSilo CP_job_siloLoader CP_job_combineUnload - + CP_job_street diff --git a/config/MasterTranslations.xml b/config/MasterTranslations.xml index 6c3ce8357..92263a7f5 100644 --- a/config/MasterTranslations.xml +++ b/config/MasterTranslations.xml @@ -91,8 +91,20 @@ + + + + + + + + + + + + - + @@ -207,6 +219,14 @@ + + + + + + + + @@ -225,26 +245,10 @@ - - - - - - - - - - - - - - - - @@ -253,6 +257,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -326,6 +354,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -347,6 +446,10 @@ + + + + @@ -1694,6 +1797,180 @@ The course is saved automatically on closing of the editor and overrides the sel --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1868,6 +2145,10 @@ The course is saved automatically on closing of the editor and overrides the sel + + + + @@ -3200,6 +3481,10 @@ TPS extension + + + + + + diff --git a/config/gui/dialog/FilltypeSelectionDialog.xml b/config/gui/dialog/FilltypeSelectionDialog.xml new file mode 100644 index 000000000..d981f3e0c --- /dev/null +++ b/config/gui/dialog/FilltypeSelectionDialog.xml @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + - + 1 2 diff --git a/config/jobParameters/BunkerSiloJobParameterSetup.xml b/config/jobParameters/BunkerSiloJobParameterSetup.xml index 07ddc98ee..426d48b70 100644 --- a/config/jobParameters/BunkerSiloJobParameterSetup.xml +++ b/config/jobParameters/BunkerSiloJobParameterSetup.xml @@ -7,7 +7,10 @@ - + + diff --git a/config/jobParameters/CombineUnloaderJobParameterSetup.xml b/config/jobParameters/CombineUnloaderJobParameterSetup.xml index bbc44d6c1..b6ecec764 100644 --- a/config/jobParameters/CombineUnloaderJobParameterSetup.xml +++ b/config/jobParameters/CombineUnloaderJobParameterSetup.xml @@ -7,7 +7,10 @@ - + + @@ -24,15 +27,30 @@ - - - - - - - - - - + + + + 1 + 2 + 3 + 4 + + + CP_deactivated + giants + streetMode + field + + + + + + diff --git a/config/jobParameters/FieldWorkJobParameterSetup.xml b/config/jobParameters/FieldWorkJobParameterSetup.xml index 3e8f5b711..835bc19c9 100644 --- a/config/jobParameters/FieldWorkJobParameterSetup.xml +++ b/config/jobParameters/FieldWorkJobParameterSetup.xml @@ -7,13 +7,16 @@ - + + - + 1 2 @@ -30,4 +33,18 @@ + + + + + + + diff --git a/config/jobParameters/FillTypeSelectionParameterSetup.xml b/config/jobParameters/FillTypeSelectionParameterSetup.xml new file mode 100644 index 000000000..4734b8b3a --- /dev/null +++ b/config/jobParameters/FillTypeSelectionParameterSetup.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/config/jobParameters/SiloLoaderJobParameterSetup.xml b/config/jobParameters/SiloLoaderJobParameterSetup.xml index 21e3e0122..1a8154b84 100644 --- a/config/jobParameters/SiloLoaderJobParameterSetup.xml +++ b/config/jobParameters/SiloLoaderJobParameterSetup.xml @@ -7,7 +7,10 @@ - + + @@ -24,6 +27,7 @@ - + diff --git a/config/jobParameters/StreetJobParameterSetup.xml b/config/jobParameters/StreetJobParameterSetup.xml new file mode 100644 index 000000000..6709d4da0 --- /dev/null +++ b/config/jobParameters/StreetJobParameterSetup.xml @@ -0,0 +1,39 @@ + + + + + + + 0 + 1 + 2 + + + driveTo + unloadAtTarget + loadAndUnload + + + + + + + + + + + + + + + + + + diff --git a/modDesc.xml b/modDesc.xml index 85c147bdf..07f33df85 100644 --- a/modDesc.xml +++ b/modDesc.xml @@ -83,6 +83,7 @@ Changelog 8.0.0.0: + @@ -141,6 +142,12 @@ Changelog 8.0.0.0: + + + + + + @@ -168,6 +175,7 @@ Changelog 8.0.0.0: + @@ -244,14 +252,20 @@ Changelog 8.0.0.0: - + + + + - + + + + @@ -259,6 +273,7 @@ Changelog 8.0.0.0: + @@ -268,18 +283,25 @@ Changelog 8.0.0.0: + + + - + + + + + @@ -296,6 +318,7 @@ Changelog 8.0.0.0: + @@ -307,8 +330,22 @@ Changelog 8.0.0.0: + + + + + + + + + + + + + + @@ -321,6 +358,9 @@ Changelog 8.0.0.0: + + + @@ -338,7 +378,7 @@ Changelog 8.0.0.0: - + @@ -406,6 +446,9 @@ Changelog 8.0.0.0: + + + @@ -434,7 +477,8 @@ Changelog 8.0.0.0: - + + diff --git a/scripts/Course.lua b/scripts/Course.lua index 0e5a178ea..110efe787 100644 --- a/scripts/Course.lua +++ b/scripts/Course.lua @@ -1671,7 +1671,7 @@ function Course.createFromAnalyticPath(vehicle, path, isTemporary) local course = Course(vehicle, CpMathUtil.pointsToGameInPlace(path), isTemporary) -- enrichWaypointData rotated the last waypoint in the direction of the second to last, -- correct that according to the analytic path's last waypoint - local yRot = CpMathUtil.angleToGame(path[#path].t) + local yRot = CpMathUtil.angleToGame(path[#path].t or 0) course.waypoints[#course.waypoints].yRot = yRot course.waypoints[#course.waypoints].angle = math.deg(yRot) course.waypoints[#course.waypoints].dx, course.waypoints[#course.waypoints].dz = MathUtil.getDirectionFromYRotation(yRot) diff --git a/scripts/CpSettingsUtil.lua b/scripts/CpSettingsUtil.lua index d14791152..a09c1e423 100644 --- a/scripts/CpSettingsUtil.lua +++ b/scripts/CpSettingsUtil.lua @@ -31,6 +31,7 @@ CpSettingsUtil = {} - textInput(bool) : is text input allowed ? (optional), every automatic generated number sequence is automatically allowed. - isUserSetting(bool): should the setting be saved in the game settings and not in the savegame dir. - isExpertModeOnly(bool): is the setting visible in the expert version?, default = false + - isCopyValueDisabled(bool): is the setting value being copy by a copy function call?, default false - generateValuesFunction(string): dynamically adds value, when the setting is created. - min (float): min value @@ -85,6 +86,7 @@ function CpSettingsUtil.init() schema:register(XMLValueType.BOOL, key .. "#textInput", "Setting input text allowed.") --optional schema:register(XMLValueType.BOOL, key .. "#isUserSetting", "Setting will be saved in the gameSettings file.", false) --optional schema:register(XMLValueType.BOOL, key.."#isExpertModeOnly", "Is enabled in simple mode?", false) -- optional + schema:register(XMLValueType.BOOL, key.."#isCopyValueDisabled", "Is value copied by copy()?", false) -- optional schema:register(XMLValueType.STRING, key .. "#generateValuesFunction", "Function to generate values.") schema:register(XMLValueType.FLOAT, key.."#min", "Setting min value") @@ -198,6 +200,7 @@ function CpSettingsUtil.loadSettingsFromSetup(class, filePath) settingParameters.textInputAllowed = xmlFile:getValue(baseKey.."#textInput", false) settingParameters.isUserSetting = xmlFile:getValue(baseKey.."#isUserSetting", false) settingParameters.isExpertModeOnly = xmlFile:getValue(baseKey.."#isExpertModeOnly", false) + settingParameters.isCopyValueDisabled = xmlFile:getValue(baseKey.."#isCopyValueDisabled", false) settingParameters.generateValuesFunction = xmlFile:getValue(baseKey.."#generateValuesFunction") settingParameters.min = xmlFile:getValue(baseKey.."#min") @@ -385,7 +388,7 @@ end ---@param class table function CpSettingsUtil.generateAiJobGuiElementsFromSettingsTable(settingsBySubTitle, class, settings) for _, data in ipairs(settingsBySubTitle) do - local parameterGroup = AIParameterGroup.new(g_i18n:getText(data.title)) + local parameterGroup = CpAIParameterGroup(settings, data) for _, setting in ipairs(data.elements) do local s = settings[setting:getName()] parameterGroup:addParameter(s) diff --git a/scripts/ai/PathfinderController.lua b/scripts/ai/PathfinderController.lua index 2cf13685a..3db7173d2 100644 --- a/scripts/ai/PathfinderController.lua +++ b/scripts/ai/PathfinderController.lua @@ -335,6 +335,22 @@ function PathfinderController:findPathToGoal(context, goal, numRetries) return true end +function PathfinderController:findPathOnStreet(context, goal, numRetries) + if not self.callbackSuccessFunction then + self:error("No valid success callback was given!") + return false + end + self:start(context, numRetries, + function() + local pathfinder = GraphPathfinder(100, 1000, 25, g_graph:getGraphEdges()) + local start = PathfinderUtil.getVehiclePositionAsState3D(self.vehicle) + -- no constraints + return pathfinder, pathfinder:start(start, goal, AIUtil.getTurningRadius(self.vehicle), false, PathfinderConstraintInterface(), 0) + end + ) + return true +end + --- Generate an analytic path from the vehicle's current position to a goal position --- Does not need a context ---@param goal State3D goal pose diff --git a/scripts/ai/ProximitySensor.lua b/scripts/ai/ProximitySensor.lua index 136d8f795..dd9f0ed17 100644 --- a/scripts/ai/ProximitySensor.lua +++ b/scripts/ai/ProximitySensor.lua @@ -99,7 +99,7 @@ function ProximitySensor:update() 2 * self.maxRotation)) end - local x, _, z = localToWorld(self.node, self.xOffset, 0, 0) + local x, y, z = localToWorld(self.node, self.xOffset, 0, 0) -- we want the rays run parallel to the terrain, so always use the terrain height (because the node itself -- can be under ground at sudden elevation changes, even node y + height, and when the ray starts from under -- the ground, it seems to cause a hit, even if it should not for the terrain @@ -107,6 +107,10 @@ function ProximitySensor:update() -- get the terrain height at the end of the raycast line local tx, _, tz = localToWorld(self.node, self.dx + self.xOffset, 0, self.dz) local y2 = getTerrainHeightAtWorldPos(g_currentMission.terrainRootNode, tx, 0, tz) + if math.abs(y - y1) > 1.5 * self.height then + -- workaround: likely on a bridge, the terrain height at the node shouldn't be much more than height + y1, y2 = y - self.height, y - self.height + end -- make sure the raycast line is parallel with the ground local ny = (y2 - y1) / self.range local nx, _, nz = localDirectionToWorld(self.node, self.lx, 0, self.lz) diff --git a/scripts/ai/jobs/CpAIJob.lua b/scripts/ai/jobs/CpAIJob.lua index 72fa0eca9..b7ae19eb8 100644 --- a/scripts/ai/jobs/CpAIJob.lua +++ b/scripts/ai/jobs/CpAIJob.lua @@ -37,7 +37,11 @@ end --- Setup all tasks. function CpAIJob:setupTasks(isServer) - self.driveToTask = AITaskDriveTo.new(isServer, self) + if true then + self.driveToTask = CpAITaskDriveToPoint(isServer, self) + else + self.driveToTask = AITaskDriveTo.new(isServer, self) + end self:addTask(self.driveToTask) end @@ -46,7 +50,7 @@ end function CpAIJob:setupJobParameters() self.vehicleParameter = AIParameterVehicle.new() self:addNamedParameter("vehicle", self.vehicleParameter) - local vehicleGroup = AIParameterGroup.new(g_i18n:getText("ai_parameterGroupTitleVehicle")) + local vehicleGroup = CpAIParameterGroup(nil, {title = "ai_parameterGroupTitleVehicle"}) vehicleGroup:addParameter(self.vehicleParameter) table.insert(self.groupedParameters, vehicleGroup) end @@ -68,40 +72,37 @@ end --- Gets the first task to start with. function CpAIJob:getStartTaskIndex() - if self.currentTaskIndex ~= 0 or self.isDirectStart or self:isTargetReached() then - -- skip Giants driveTo - -- TODO: this isn't very nice as we rely here on the derived classes to add more tasks - return 2 - end - if self.driveToTask.x == nil then - CpUtil.info("Drive to task was skipped, as no valid start position is set!") + if self.driveToTask and (self:isTargetReached() or self.isDirectStart) then return 2 end + + -- if self.currentTaskIndex ~= 0 or self.isDirectStart or self:isTargetReached() then + -- -- skip Giants driveTo + -- -- TODO: this isn't very nice as we rely here on the derived classes to add more tasks + -- return 2 + -- end + -- if self.driveToTask:isa(AITaskDriveTo) and self.driveToTask.x == nil then + -- CpUtil.info("Drive to task was skipped, as no valid start position is set!") + -- return 2 + -- end return 1 end -function CpAIJob:getNextTaskIndex() - if self:getIsLooping() and self.currentTaskIndex >= #self.tasks then - --- Makes sure the giants task is skipped - return self:getStartTaskIndex() - end - return AIJob.getNextTaskIndex(self) -end +-- function CpAIJob:getNextTaskIndex() +-- if self:getIsLooping() and self.currentTaskIndex >= #self.tasks then +-- --- Makes sure the giants task is skipped +-- return self:getStartTaskIndex() +-- end +-- return AIJob.getNextTaskIndex(self) +-- end ---- Should the giants path finder job be skipped? +--- Are we near the target point anyway or do we want to skip the inital street drive? function CpAIJob:isTargetReached() - if not self.cpJobParameters or not self.cpJobParameters.startPosition then - return true - end - local vehicle = self.vehicleParameter:getVehicle() - local x, _, z = getWorldTranslation(vehicle.rootNode) - local tx, tz = self.cpJobParameters.startPosition:getPosition() - if tx == nil or tz == nil then + if not self.cpJobParameters or not self.cpJobParameters.startTargetPoint then return true end - local targetReached = MathUtil.vector2Length(x - tx, z - tz) < 3 - - return targetReached + --- Override by sub classes + return false end function CpAIJob:onPreStart() @@ -222,14 +223,18 @@ function CpAIJob:setValues() local vehicle = self.vehicleParameter:getVehicle() - self.driveToTask:setVehicle(vehicle) + if self.driveToTask then + self.driveToTask:setVehicle(vehicle) + self.driveToTask:setTarget( + g_graph:getTargetByUniqueID(self.cpJobParameters.startTargetPoint:getValue())) - local angle = self.cpJobParameters.startPosition:getAngle() - local x, z = self.cpJobParameters.startPosition:getPosition() - if angle ~= nil and x ~= nil then - local dirX, dirZ = MathUtil.getDirectionFromYRotation(angle) - self.driveToTask:setTargetDirection(dirX, dirZ) - self.driveToTask:setTargetPosition(x, z) + -- local angle = self.cpJobParameters.startPosition:getAngle() + -- local x, z = self.cpJobParameters.startPosition:getPosition() + -- if angle ~= nil and x ~= nil then + -- local dirX, dirZ = MathUtil.getDirectionFromYRotation(angle) + -- self.driveToTask:setTargetDirection(dirX, dirZ) + -- self.driveToTask:setTargetPosition(x, z) + -- end end end @@ -525,5 +530,6 @@ function CpAIJob.registerJob(aiJobTypeManager) register(CpAIJobCombineUnloader) register(CpAIJobSiloLoader) register(CpAIJobBunkerSilo) + register(CpAIJobStreet) end diff --git a/scripts/ai/jobs/CpAIJobBaleFinder.lua b/scripts/ai/jobs/CpAIJobBaleFinder.lua index 49ab782d9..be543404b 100644 --- a/scripts/ai/jobs/CpAIJobBaleFinder.lua +++ b/scripts/ai/jobs/CpAIJobBaleFinder.lua @@ -15,6 +15,9 @@ function CpAIJobBaleFinder:setupTasks(isServer) CpAIJob.setupTasks(self, isServer) self.baleFinderTask = CpAITaskBaleFinder(isServer, self) self:addTask(self.baleFinderTask) + self.unloadTask = CpAITaskDriveToPointUnload(isServer, self) + self:addTask(self.unloadTask) + end function CpAIJobBaleFinder:setupJobParameters() @@ -47,6 +50,9 @@ function CpAIJobBaleFinder:setValues() CpAIJob.setValues(self) local vehicle = self.vehicleParameter:getVehicle() self.baleFinderTask:setVehicle(vehicle) + self.unloadTask:setVehicle(vehicle) + self.unloadTask:setTarget( + g_graph:getTargetByUniqueID(self.cpJobParameters.unloadTargetPoint:getValue())) end --- Called when parameters change, scan field @@ -63,6 +69,15 @@ function CpAIJobBaleFinder:validate(farmId) --- Validate field setup -------------------------------------------------------------- isValid, isRunning, errorMessage = self:detectFieldBoundary(isValid, errorMessage) + -------------------------------------------------------------- + --- Validate Unload target point + -------------------------------------------------------------- + if self.cpJobParameters.unloadTargetPointEnabled:getValue() then + if self.cpJobParameters.unloadTargetPoint:getValue() < 0 then + return false, g_i18n:getText("CP_error_no_target_selected") + end + end + -- if the field detection is still running, it's ok return isValid or isRunning, errorMessage end @@ -101,3 +116,7 @@ function CpAIJobBaleFinder:getDescription() end return desc end + +function CpAIJobBaleFinder:getIsLooping() + return self.cpJobParameters.unloadTargetPointEnabled:getValue() +end \ No newline at end of file diff --git a/scripts/ai/jobs/CpAIJobCombineUnloader.lua b/scripts/ai/jobs/CpAIJobCombineUnloader.lua index e45693c2d..f05cb57ba 100644 --- a/scripts/ai/jobs/CpAIJobCombineUnloader.lua +++ b/scripts/ai/jobs/CpAIJobCombineUnloader.lua @@ -17,6 +17,8 @@ function CpAIJobCombineUnloader:init(isServer) self.heapNode = CpUtil.createNode("siloNode", 0, 0, 0, nil) --- Giants unload self.dischargeNodeInfos = {} + + self.useGiantsUnload = false end function CpAIJobCombineUnloader:delete() @@ -29,23 +31,28 @@ function CpAIJobCombineUnloader:setupTasks(isServer) self.combineUnloaderTask = CpAITaskCombineUnloader(isServer, self) self:addTask(self.combineUnloaderTask) - --- Giants unload - self.waitForFillingTask = self.combineUnloaderTask - self.driveToUnloadingTask = AITaskDriveTo.new(isServer, self) - self.dischargeTask = AITaskDischarge.new(isServer, self) - self:addTask(self.driveToUnloadingTask) - self:addTask(self.dischargeTask) - + if self.useGiantsUnload then + --- Giants unload + self.waitForFillingTask = self.combineUnloaderTask + self.driveToUnloadingTask = AITaskDriveTo.new(isServer, self) + self.dischargeTask = AITaskDischarge.new(isServer, self) + self:addTask(self.driveToUnloadingTask) + self:addTask(self.dischargeTask) + else + self.driveToUnloadingTask = CpAITaskDriveToPointUnload(isServer, self) + self:addTask(self.driveToUnloadingTask) + end end function CpAIJobCombineUnloader:setupJobParameters() CpAIJob.setupJobParameters(self) self:setupCpJobParameters(CpCombineUnloaderJobParameters(self)) self.cpJobParameters.fieldUnloadPosition:setSnappingAngle(math.pi/8) -- AI menu snapping angle of 22.5 degree. - --- Giants unload - self.unloadingStationParameter = self.cpJobParameters.unloadingStation - self.waitForFillingTask = self.combineUnloaderTask + if self.useGiantsUnload then + self.unloadingStationParameter = self.cpJobParameters.unloadingStation + self.waitForFillingTask = self.combineUnloaderTask + end end function CpAIJobCombineUnloader:getIsAvailableForVehicle(vehicle, cpJobsAllowed) @@ -105,7 +112,13 @@ function CpAIJobCombineUnloader:setValues() CpAIJob.setValues(self) local vehicle = self.vehicleParameter:getVehicle() self.combineUnloaderTask:setVehicle(vehicle) - self:setupGiantsUnloaderData(vehicle) + if self.useGiantsUnload then + self:setupGiantsUnloaderData(vehicle) + else + self.driveToUnloadingTask:setVehicle(vehicle) + self.driveToUnloadingTask:setTarget( + g_graph:getTargetByUniqueID(self.cpJobParameters.unloadTargetPoint:getValue())) + end end --- Called when parameters change, scan field @@ -120,10 +133,15 @@ function CpAIJobCombineUnloader:validate(farmId) if vehicle then vehicle:applyCpCombineUnloaderJobParameters(self) end + + local useGiantsUnload = self.cpJobParameters.unloadWith:getValue() == CpCombineUnloaderJobParameters.UNLOAD_WITH_GIANTS + local useFieldUnload = self.cpJobParameters.unloadWith:getValue() == CpCombineUnloaderJobParameters.UNLOAD_ON_FIELD + local useStreetModeUnload = self.cpJobParameters.unloadWith:getValue() == CpCombineUnloaderJobParameters.UNLOAD_WITH_STREET_MODE + ------------------------------------ --- Validate giants unload if needed ------------------------------------- - if not self.cpJobParameters.useGiantsUnload:getIsDisabled() and self.cpJobParameters.useGiantsUnload:getValue() then + if useGiantsUnload then isValid, errorMessage = self.cpJobParameters.unloadingStation:validateUnloadingStation() if not isValid then @@ -140,6 +158,15 @@ function CpAIJobCombineUnloader:validate(farmId) end end + --------------------------------------------- + --- Validate street unload target if needed + --------------------------------------------- + if useStreetModeUnload then + if self.cpJobParameters.unloadTargetPoint:getValue() < 0 then + return false, g_i18n:getText("CP_error_no_target_selected") + end + end + ------------------------------------ --- Validate selected field ------------------------------------- @@ -160,15 +187,14 @@ function CpAIJobCombineUnloader:onFieldBoundaryDetectionFinished(vehicle, fieldP self.selectedFieldPlot:setWaypoints(fieldPolygon) self.selectedFieldPlot:setVisible(true) end + local useGiantsUnload = self.cpJobParameters.unloadWith:getValue() == CpCombineUnloaderJobParameters.UNLOAD_WITH_GIANTS + local useFieldUnload = self.cpJobParameters.unloadWith:getValue() == CpCombineUnloaderJobParameters.UNLOAD_ON_FIELD + local useStreetModeUnload = self.cpJobParameters.unloadWith:getValue() == CpCombineUnloaderJobParameters.UNLOAD_WITH_STREET_MODE ------------------------------------ --- Validate start distance to field ------------------------------------- - local useGiantsUnload = false - if not self.cpJobParameters.useGiantsUnload:getIsDisabled() then - useGiantsUnload = self.cpJobParameters.useGiantsUnload:getValue() - end - local isValid, errorMessage = true - if fieldPolygon and self.isDirectStart then + local isValid, errorMessage = true, "" + if not useStreetModeUnload and fieldPolygon and self.isDirectStart then --- Checks the distance for starting with the hud, as a safety check. --- Firstly check, if the vehicle is near the field. local x, _, z = getWorldTranslation(vehicle.rootNode) @@ -190,12 +216,7 @@ function CpAIJobCombineUnloader:onFieldBoundaryDetectionFinished(vehicle, fieldP ------------------------------------ --- Validate field unload if needed ------------------------------------- - local useFieldUnload = false - if not self.cpJobParameters.useFieldUnload:getIsDisabled() then - useFieldUnload = self.cpJobParameters.useFieldUnload:getValue() - end if useFieldUnload then - local x, z = self.cpJobParameters.fieldUnloadPosition:getPosition() isValid = CpMathUtil.isPointInPolygon(fieldPolygon, x, z) or CpMathUtil.isWithinDistanceToPolygon(fieldPolygon, x, z, self.minFieldUnloadDistanceToField) @@ -281,6 +302,9 @@ function CpAIJobCombineUnloader:setupGiantsUnloaderData(vehicle) end function CpAIJobCombineUnloader:getNextTaskIndex(isSkipTask) + if not self.useGiantsUnload then + return CpAIJob.getNextTaskIndex(self, isSkipTask) + end --- Giants unload, sets the correct dischargeNode and vehicle and unload target information. local index = AIJobDeliver.getNextTaskIndex(self, isSkipTask) return index @@ -292,8 +316,7 @@ function CpAIJobCombineUnloader:canContinueWork() return canContinueWork, errorMessage end --- Giants unload, checks if the unloading station is still available and not full. - if self.cpJobParameters.useGiantsUnload:getValue() then - + if self.useGiantsUnload then local canContinue, errorMessage = AIJobDeliver.canContinueWork(self) if not canContinue then return canContinue, errorMessage @@ -315,15 +338,16 @@ function CpAIJobCombineUnloader:canContinueWork() end end end - return true, nil end function CpAIJobCombineUnloader:startTask(task) --- Giants unload, reset the discharge nodes before unloading. - if task == self.driveToUnloadingTask then - for _, dischargeNodeInfo in ipairs(self.dischargeNodeInfos) do - dischargeNodeInfo.dirty = true + if self.useGiantsUnload then + if task == self.driveToUnloadingTask then + for _, dischargeNodeInfo in ipairs(self.dischargeNodeInfos) do + dischargeNodeInfo.dirty = true + end end end CpAIJob.startTask(self, task) @@ -335,7 +359,7 @@ end ---@return number function CpAIJobCombineUnloader:getStartTaskIndex() local startTask = CpAIJob.getStartTaskIndex(self) - if not self.cpJobParameters.useGiantsUnload:getValue() then + if not self.useGiantsUnload then return startTask end local vehicle = self:getVehicle() diff --git a/scripts/ai/jobs/CpAIJobFieldWork.lua b/scripts/ai/jobs/CpAIJobFieldWork.lua index 91c1e86c7..301d24ec9 100644 --- a/scripts/ai/jobs/CpAIJobFieldWork.lua +++ b/scripts/ai/jobs/CpAIJobFieldWork.lua @@ -25,6 +25,9 @@ function CpAIJobFieldWork:setupTasks(isServer) self.attachHeaderTask = CpAITaskAttachHeader(isServer, self) self.driveToFieldWorkStartTask = CpAITaskDriveTo(isServer, self) self.fieldWorkTask = CpAITaskFieldWork(isServer, self) + self.unloadTask = CpAITaskDriveToPointUnload(isServer, self) + self.refillTask = CpAITaskDriveToPointLoad(isServer, self) + self.refillTaskExtra = CpAITaskDriveToPointLoad(isServer, self) end function CpAIJobFieldWork:onPreStart() @@ -32,6 +35,10 @@ function CpAIJobFieldWork:onPreStart() self:removeTask(self.attachHeaderTask) self:removeTask(self.driveToFieldWorkStartTask) self:removeTask(self.fieldWorkTask) + self:removeTask(self.unloadTask) + self:removeTask(self.refillTask) + self:removeTask(self.refillTaskExtra) + local vehicle = self:getVehicle() if vehicle and (AIUtil.hasCutterOnTrailerAttached(vehicle) or AIUtil.hasCutterAsTrailerAttached(vehicle)) then @@ -40,6 +47,14 @@ function CpAIJobFieldWork:onPreStart() end self:addTask(self.driveToFieldWorkStartTask) self:addTask(self.fieldWorkTask) + if not self.cpJobParameters:isUnloadingDisabled() then + self:addTask(self.unloadTask) + elseif not self.cpJobParameters:isLoadingDisabled() then + self:addTask(self.refillTask) + if not self.cpJobParameters:isUnloadRefillExtraDisabled() then + self:addTask(self.refillTaskExtra) + end + end end function CpAIJobFieldWork:setupJobParameters() @@ -48,13 +63,18 @@ function CpAIJobFieldWork:setupJobParameters() end function CpAIJobFieldWork:isFinishingAllowed(message) - local nextTaskIndex = self:getNextTaskIndex() + if not self.cpJobParameters:isUnloadOrRefillingDisabled() then + if message:isa(AIMessageErrorOutOfFill) or + message:isa(AIMessageErrorIsFull) then + --- Street refill + self.fieldWorkTask:skip() + return false + end + end if message:isa(AIMessageErrorOutOfFill) then --- At least one implement type needs to be refilled. - local vehicle = self:getVehicle() local setting = vehicle:getCpSettings().refillOnTheField - if setting:getValue() == CpVehicleSettings.REFILL_ON_FIELD_DISABLED then return true elseif setting:getValue() == CpVehicleSettings.REFILL_ON_FIELD_WAITING then @@ -77,6 +97,7 @@ end ---@param resetToVehiclePosition boolean resets the drive to target position by giants and the field position to the vehicle position. function CpAIJobFieldWork:applyCurrentState(vehicle, mission, farmId, isDirectStart, resetToVehiclePosition) CpAIJob.applyCurrentState(self, vehicle, mission, farmId, isDirectStart) + self:copyFrom(vehicle:getCpFieldWorkerJob()) if resetToVehiclePosition then -- set the start and the field position to the vehicle's position ( local x, _, z = getWorldTranslation(vehicle.rootNode) @@ -127,6 +148,15 @@ function CpAIJobFieldWork:setValues() self.driveToFieldWorkStartTask:setVehicle(vehicle) self.attachHeaderTask:setVehicle(vehicle) self.fieldWorkTask:setVehicle(vehicle) + self.refillTask:setVehicle(vehicle) + self.refillTask:setTarget( + g_graph:getTargetByUniqueID(self.cpJobParameters.unloadRefillTargetPoint:getValue())) + self.unloadTask:setVehicle(vehicle) + self.unloadTask:setTarget( + g_graph:getTargetByUniqueID(self.cpJobParameters.unloadRefillTargetPoint:getValue())) + self.refillTaskExtra:setVehicle(vehicle) + self.refillTaskExtra:setTarget( + g_graph:getTargetByUniqueID(self.cpJobParameters.unloadRefillTargetPointExtra:getValue())) end --- Called when parameters change, scan field @@ -137,6 +167,9 @@ function CpAIJobFieldWork:validate(farmId) return isValid, errorMessage end local vehicle = self.vehicleParameter:getVehicle() + if vehicle then + vehicle:applyCpFieldWorkerJobParameters(self) + end --- Only check the valid field position in the in game menu. if not self.isDirectStart then @@ -147,6 +180,18 @@ function CpAIJobFieldWork:validate(farmId) self.cpJobParameters:validateSettings() end + if not self.cpJobParameters:isUnloadOrRefillingDisabled() then + if self.cpJobParameters.unloadRefillTargetPoint:getValue() < 0 then + return false, g_i18n:getText("CP_error_no_target_selected") + end + if not self.cpJobParameters:isUnloadRefillExtraDisabled() then + if self.cpJobParameters.unloadRefillTargetPointExtra:getValue() < 0 then + return false, g_i18n:getText("CP_error_no_target_selected") + end + end + --- TODO fill type evaluation + end + if not vehicle:hasCpCourse() then return false, g_i18n:getText("CP_error_no_course") end @@ -281,3 +326,7 @@ function CpAIJobFieldWork:getDescription() end return desc end + +function CpAIJobFieldWork:getIsLooping() + return not self.cpJobParameters:isUnloadOrRefillingDisabled() +end \ No newline at end of file diff --git a/scripts/ai/jobs/CpAIJobStreet.lua b/scripts/ai/jobs/CpAIJobStreet.lua new file mode 100644 index 000000000..50ff29581 --- /dev/null +++ b/scripts/ai/jobs/CpAIJobStreet.lua @@ -0,0 +1,112 @@ +--- Street job. +---@class CpAIJobStreet : CpAIJob +CpAIJobStreet = CpObject(CpAIJob) +CpAIJobStreet.name = "STREET_WORKER_CP" +CpAIJobStreet.jobName = "CP_job_street" +function CpAIJobStreet:init(isServer) + CpAIJob.init(self, isServer) + +end + +function CpAIJobStreet:setupTasks(isServer) + self.driveToPointTask = CpAITaskDriveToPoint(isServer, self) + self.driveToLoadingTask = CpAITaskDriveToPointLoad(isServer, self) + self.driveToUnloadingTask = CpAITaskDriveToPointUnload(isServer, self) +end + +function CpAIJobStreet:onPreStart() + self:removeTask(self.driveToPointTask) + self:removeTask(self.driveToLoadingTask) + self:removeTask(self.driveToUnloadingTask) + if self.cpJobParameters.loadUnloadTargetMode:getValue() == CpStreetJobParameters.UNLOAD_AT_TARGET then + self:addTask(self.driveToUnloadingTask) + elseif self.cpJobParameters.loadUnloadTargetMode:getValue() == CpStreetJobParameters.LOAD_AND_UNLOAD then + self:addTask(self.driveToLoadingTask) + self:addTask(self.driveToUnloadingTask) + else + self:addTask(self.driveToPointTask) + end + self.driveToPointTask:setTarget( + g_graph:getTargetByUniqueID(self.cpJobParameters.unloadTargetPoint:getValue())) + self.driveToUnloadingTask:setTarget( + g_graph:getTargetByUniqueID(self.cpJobParameters.unloadTargetPoint:getValue())) + self.driveToLoadingTask:setTarget( + g_graph:getTargetByUniqueID(self.cpJobParameters.loadTargetPoint:getValue())) +end + +function CpAIJobStreet:setupJobParameters() + CpAIJob.setupJobParameters(self) + self:setupCpJobParameters(CpStreetJobParameters(self)) +end + +function CpAIJobStreet:getCanStartJob() + return true +end + +function CpAIJobStreet:getStartTaskIndex() + --- TODO Filltype check ?? + return 1 +end + +function CpAIJobStreet:applyCurrentState(vehicle, mission, farmId, isDirectStart, isStartPositionInvalid) + CpAIJob.applyCurrentState(self, vehicle, mission, farmId, isDirectStart) + self.cpJobParameters:validateSettings() + + self:copyFrom(vehicle:getCpStreetWorkerJob()) + +end + +function CpAIJobStreet:setValues() + CpAIJob.setValues(self) + local vehicle = self.vehicleParameter:getVehicle() + self.driveToPointTask:setVehicle(vehicle) + self.driveToUnloadingTask:setVehicle(vehicle) + self.driveToLoadingTask:setVehicle(vehicle) +end + +--- Called when parameters change, scan field +function CpAIJobStreet:validate(farmId) + local isValid, isRunning, errorMessage = CpAIJob.validate(self, farmId) + if not isValid then + return isValid, errorMessage + end + local vehicle = self.vehicleParameter:getVehicle() + if vehicle then + vehicle:applyCpStreetWorkerJobParameters(self) + end + if self.cpJobParameters.unloadTargetPoint:getValue() < 0 then + return false, g_i18n:getText("CP_error_no_target_selected") + end + if self.cpJobParameters.loadUnloadTargetMode:getValue() == CpStreetJobParameters.LOAD_AND_UNLOAD then + if self.cpJobParameters.loadTargetPoint:getValue() < 0 then + return false, g_i18n:getText("CP_error_no_target_selected") + end + elseif self.cpJobParameters.loadUnloadTargetMode:getValue() == CpStreetJobParameters.UNLOAD_AT_TARGET then + --- TODO filllevel check + end + return isValid or isRunning, errorMessage +end + +function CpAIJobStreet:draw(map, isOverviewMap) + CpAIJob.draw(self, map, isOverviewMap) + -- if not isOverviewMap then + -- self.selectedFieldPlot:draw(map) + -- end +end + +--- Gets the additional task description shown. +function CpAIJobStreet:getDescription() + local desc = CpAIJob.getDescription(self) + -- local currentTask = self:getTaskByIndex(self.currentTaskIndex) + -- if currentTask == self.driveToTask then + -- desc = desc .. " - " .. g_i18n:getText("ai_taskDescriptionDriveToField") + -- elseif currentTask == self.baleFinderTask then + -- local vehicle = self:getVehicle() + -- if vehicle and AIUtil.hasChildVehicleWithSpecialization(vehicle, BaleWrapper) then + -- desc = desc .. " - " .. g_i18n:getText("CP_ai_taskDescriptionWrapsBales") + -- else + -- desc = desc .. " - " .. g_i18n:getText("CP_ai_taskDescriptionCollectsBales") + -- end + -- end + return desc +end diff --git a/scripts/ai/jobs/CpJobParameters.lua b/scripts/ai/jobs/CpJobParameters.lua index 9599b77ce..9b768417d 100644 --- a/scripts/ai/jobs/CpJobParameters.lua +++ b/scripts/ai/jobs/CpJobParameters.lua @@ -44,6 +44,12 @@ function CpJobParameters:validateSettings(includeDisabledValues) end end +function CpJobParameters:resetToLoadedValue(includeDisabledValues) + for i, setting in ipairs(self.settings) do + setting:resetToLoadedValue() + end +end + function CpJobParameters:writeStream(streamId, connection) for i, setting in ipairs(self.settings) do setting:writeStream(streamId, connection) @@ -119,6 +125,14 @@ function CpJobParameters:isFieldWorkHudModeDisabled() return false end +function CpJobParameters:isStreetModeActive() + return true +end + +function CpJobParameters:isGiantsForStreetActive() + return false +end + --- Callback raised by a setting and executed as an vehicle event. ---@param callbackStr string event to be raised ---@param setting AIParameterSettingList setting that raised the callback. @@ -141,6 +155,44 @@ function CpJobParameters:isCpActive() return self.job:getVehicle() and self.job:getVehicle():getIsCpActive() end +--- Crawls through the parameters and collects all CpAIParameterFillTypeSetting settings. +---@return table +function CpJobParameters:getFillTypeSelectionSettings() + local parameters = {} + for i, setting in ipairs(self.settings) do + if setting:is_a(CpAIParameterFillTypeSetting) then + table.insert(parameters, setting) + end + end + return parameters +end + +--- Crawls through the parameters and collects all CpAITargetPointSetting settings. +---@return table +function CpJobParameters:getTargetPointSettings() + local parameters = {} + for i, setting in ipairs(self.settings) do + if setting:is_a(CpAIParameterTargetPoint) then + table.insert(parameters, setting) + end + end + return parameters +end + + +--- Are the setting values roughly equal. +---@param otherParameters CpJobParameters +---@return boolean +function CpJobParameters:areAlmostEqualTo(otherParameters) + for i, param in pairs(self.settings) do + if not param:isAlmostEqualTo(otherParameters[param:getName()]) then + CpUtil.debugFormat(CpDebug.DBG_HUD, "Parameter: %s not equal!", param:getName()) + return false + end + end + return true +end + ---@class CpFieldWorkJobParameters : CpJobParameters CpFieldWorkJobParameters = CpObject(CpJobParameters) function CpFieldWorkJobParameters:init(job) @@ -226,19 +278,94 @@ function CpFieldWorkJobParameters:isLaneOffsetDisabled() return self:noMultiToolsCourseSelected() or vehicle and vehicle:getIsCpActive() end ---- Are the setting values roughly equal. ----@param otherParameters CpJobParameters ----@return boolean -function CpJobParameters:areAlmostEqualTo(otherParameters) - for i, param in pairs(self.settings) do - if not param:isAlmostEqualTo(otherParameters[param:getName()]) then - CpUtil.debugFormat(CpDebug.DBG_HUD, "Parameter: %s not equal!", param:getName()) - return false +function CpFieldWorkJobParameters:isUnloadRefillModeDisabled() + return false --- TODO: Added check for refill/unload possibilities ... +end + + +function CpFieldWorkJobParameters:generateFillTypes() + local fillTypes = {} + local texts = {} + local vehicle = self.job and self.job:getVehicle() + if vehicle then + fillTypes = FillLevelUtil.getAllValidFillTypes(vehicle, function() + return true + end) + for _, f in pairs(fillTypes) do + table.insert(texts, g_fillTypeManager:getFillTypeTitleByIndex(f)) + end + else + for ix, _ in pairs(g_fillTypeManager:getFillTypes()) do + if ix ~= FillType.UNKNOWN then + table.insert(fillTypes, ix) + table.insert(texts, g_fillTypeManager:getFillTypeTitleByIndex(f)) + end end end + table.insert(fillTypes, 1, -1) + table.insert(texts, 1, "---") + return fillTypes, texts +end + +function CpFieldWorkJobParameters:isLoadingDisabled() + if self:isUnloadRefillModeDisabled() then + return true + end + + local vehicle = self.job:getVehicle() + if not vehicle then + return false + end + return not AIUtil.hasChildVehicleWithSpecialization(vehicle, Sprayer) and + not AIUtil.hasChildVehicleWithSpecialization(vehicle, SowingMachine) +end + +function CpFieldWorkJobParameters:isUnloadingDisabled() + if self:isUnloadRefillModeDisabled() then + return true + end + local vehicle = self.job:getVehicle() + if not vehicle then + return false + end + return not AIUtil.hasChildVehicleWithSpecialization(vehicle, ForageWagon) and + not AIUtil.hasChildVehicleWithSpecialization(vehicle, BaleLoader) +end + +function CpFieldWorkJobParameters:isRunCounterDisabled() return true end +function CpFieldWorkJobParameters:isUnloadOrRefillingDisabled() + + local vehicle = self.job:getVehicle() + if not vehicle then + return false + end + if self:isLoadingDisabled() and self:isUnloadingDisabled() then + return true + end + return not self.unloadRefillActivated:getValue() +end + +function CpFieldWorkJobParameters:isUnloadRefillFillTypeDisabled() + return self:isUnloadOrRefillingDisabled() or self:isLoadingDisabled() +end + +function CpFieldWorkJobParameters:isUnloadRefillExtraDisabled() + local vehicle = self.job:getVehicle() + if not vehicle then + return false + end + return self:isUnloadOrRefillingDisabled() or + not (AIUtil.hasChildVehicleWithSpecialization(vehicle, Sprayer) and + AIUtil.hasChildVehicleWithSpecialization(vehicle, SowingMachine)) +end + +function CpFieldWorkJobParameters:onChangeStartUnloadRefillTargetPoint(setting) + +end + --- AI parameters for the bale finder job. ---@class CpBaleFinderJobParameters : CpJobParameters CpBaleFinderJobParameters = CpObject(CpJobParameters) @@ -260,6 +387,18 @@ function CpBaleFinderJobParameters:hasBaleLoader() return true end +function CpBaleFinderJobParameters:hasNoBaleLoader() + return not self:hasBaleLoader() +end + +function CpBaleFinderJobParameters:isUnloadTargetPointDisabled() + return not self.unloadTargetPointEnabled:getValue() +end + +function CpBaleFinderJobParameters:isBaleWrapSettingVisible() + return self:hasBaleLoader() +end + --- AI parameters for the bale finder job. ---@class CpCombineUnloaderJobParameters : CpJobParameters ---@field useGiantsUnload AIParameterBooleanSetting @@ -272,24 +411,20 @@ function CpCombineUnloaderJobParameters:init(job) end function CpCombineUnloaderJobParameters:isGiantsUnloadDisabled() - return self:hasPipe() or self.useFieldUnload:getValue() -end - -function CpCombineUnloaderJobParameters:isFieldUnloadDisabled() - return self.useGiantsUnload:getValue() + return self:hasPipe() or + self.unloadWith:getValue() ~= CpCombineUnloaderJobParameters.UNLOAD_WITH_GIANTS end -function CpCombineUnloaderJobParameters:isUnloadStationSelectorVisible() - return not self:isGiantsUnloadDisabled() and self.useGiantsUnload:getValue() +function CpCombineUnloaderJobParameters:isUnloadWithStreetModeDisabled() + return self:hasPipe() or + self.unloadWith:getValue() ~= CpCombineUnloaderJobParameters.UNLOAD_WITH_STREET_MODE end -function CpCombineUnloaderJobParameters:isFieldUnloadPositionSelectorDisabled() - return self:isFieldUnloadDisabled() or not self.useFieldUnload:getValue() +function CpCombineUnloaderJobParameters:isFieldUnloadDisabled() + return self.unloadWith:getValue() ~= CpCombineUnloaderJobParameters.UNLOAD_ON_FIELD end - - function CpCombineUnloaderJobParameters:isFieldUnloadTipSideDisabled() - return self:isFieldUnloadDisabled() or self:hasPipe() or not self.useFieldUnload:getValue() + return self:isFieldUnloadDisabled() or self:hasPipe() end function CpCombineUnloaderJobParameters:hasPipe() @@ -297,6 +432,7 @@ function CpCombineUnloaderJobParameters:hasPipe() if vehicle then return AIUtil.hasChildVehicleWithSpecialization(vehicle, Pipe) end + return false end --- Inserts the current available unloading stations into the setting values/texts. @@ -417,4 +553,72 @@ function CpSiloLoaderJobParameters:generateUnloadingStations(setting, oldIx) end end return unloadingStationIds, texts, oldIx +end + +--- AI parameters for the street driving job. +---@class CpStreetJobParameters : CpJobParameters +CpStreetJobParameters = CpObject(CpJobParameters) + +function CpStreetJobParameters:init(job) + CpJobParameters.init(self, job, + CpStreetJobParameters, "StreetJobParameterSetup.xml") +end + +function CpStreetJobParameters:hasNoValidTrailerAttached() + local vehicle = self.job:getVehicle() + if vehicle then + return not AIUtil.hasChildVehicleWithSpecialization(vehicle, Dischargeable) + or not AIUtil.hasChildVehicleWithSpecialization(vehicle, Trailer) + end + return false +end + + +function CpStreetJobParameters:isUnloadTargetPointDisabled() + return false +end + +function CpStreetJobParameters:isLoadTargetPointDisabled() + return self.loadUnloadTargetMode:getValue() ~= CpStreetJobParameters.LOAD_AND_UNLOAD +end + +function CpStreetJobParameters:isUnloadAtTargetDisabled() + return self:hasNoValidTrailerAttached() +end + +function CpStreetJobParameters:isLoadAtTargetDisabled() + return self:hasNoValidTrailerAttached() +end + +function CpStreetJobParameters:isRunCounterDisabled() + return false +end + +function CpStreetJobParameters:generateFillTypes() + local fillTypes = {} + local texts = {} + local vehicle = self.job and self.job:getVehicle() + if vehicle then + fillTypes = FillLevelUtil.getAllValidFillTypes(vehicle, function() + return true + end) + for _, f in pairs(fillTypes) do + table.insert(texts, g_fillTypeManager:getFillTypeTitleByIndex(f)) + end + else + for ix, _ in pairs(g_fillTypeManager:getFillTypes()) do + if ix ~= FillType.UNKNOWN then + table.insert(fillTypes, ix) + table.insert(texts, g_fillTypeManager:getFillTypeTitleByIndex(ix)) + end + end + end + table.insert(fillTypes, 1, -1) + table.insert(texts, 1, "---") + return fillTypes, texts +end + +---@param setting CpAIParameterTargetPoint +function CpStreetJobParameters:onChangeTargetPoints(setting) + end \ No newline at end of file diff --git a/scripts/ai/parameters/AIParameterSetting.lua b/scripts/ai/parameters/AIParameterSetting.lua index 1fa7e5c04..0f3d9ae0d 100644 --- a/scripts/ai/parameters/AIParameterSetting.lua +++ b/scripts/ai/parameters/AIParameterSetting.lua @@ -2,19 +2,20 @@ ---@class AIParameterSetting : AIParameterSettingInterface AIParameterSetting = CpObject(AIParameterSettingInterface) -function AIParameterSetting:init(name) +function AIParameterSetting:init(data, vehicle, class) AIParameterSettingInterface.init(self) - self.data = nil - self.vehicle = nil - self.class = nil + self.data = data + self.vehicle = vehicle + self.class = class - self.name = name + self.name = "" self.title = "" self.tooltip = "" self.isValid = true self.guiParameterType = nil + self.parent = nil end --- Initialize the setting from config data supplied in CpSettingsUtil @@ -59,6 +60,10 @@ function AIParameterSetting:getString() return "" end +function AIParameterSetting:getCustomIconFilename() + return nil +end + function AIParameterSetting:getIsValid() return self.isValid end @@ -67,10 +72,21 @@ function AIParameterSetting:setIsValid(isValid) self.isValid = isValid end +function AIParameterSetting:setParent(parent) + self.parent = parent +end + +function AIParameterSetting:setClass(class) + self.class = class +end + function AIParameterSetting:getIsDisabled() if self:hasCallback(self.data.isDisabledFunc) then return self:getCallback(self.data.isDisabledFunc) end + if self.parent and self.parent:getIsDisabled() then + return true + end return false end @@ -87,6 +103,9 @@ function AIParameterSetting:getIsVisible() if self:hasCallback(self.data.isVisibleFunc) then return self:getCallback(self.data.isVisibleFunc) end + if self.parent and not self.parent:getIsVisible() then + return false + end return true end diff --git a/scripts/ai/parameters/AIParameterSettingInterface.lua b/scripts/ai/parameters/AIParameterSettingInterface.lua index 424202d3d..07fe31a15 100644 --- a/scripts/ai/parameters/AIParameterSettingInterface.lua +++ b/scripts/ai/parameters/AIParameterSettingInterface.lua @@ -102,6 +102,10 @@ function AIParameterSettingInterface:refresh() end +function AIParameterSettingInterface:resetToLoadedValue() + +end + --- Sets a float value relative to the incremental. function AIParameterSettingInterface:setFloatValue(value, epsilon, noEventSend) diff --git a/scripts/ai/parameters/AIParameterSettingList.lua b/scripts/ai/parameters/AIParameterSettingList.lua index 6a39bec8d..8827ea38d 100644 --- a/scripts/ai/parameters/AIParameterSettingList.lua +++ b/scripts/ai/parameters/AIParameterSettingList.lua @@ -233,6 +233,28 @@ function AIParameterSettingList:getTextsForValues(values) return texts end +---@return number +function AIParameterSettingList:getNumberOfElements() + return #self.values +end + +---@param index number +---@return any|nil +function AIParameterSettingList:getValueByIndex(index) + return self.values[index] +end + +---@param index number +---@return string|nil +function AIParameterSettingList:getTextByIndex(index) + return self.texts[index] +end + +---@return number +function AIParameterSettingList:getCurrentIndex() + return self.current +end + function AIParameterSettingList:validateCurrentValue() local new = self:checkAndSetValidValue(self.current) if new ~= self.current then @@ -536,6 +558,10 @@ end --- Copy the value to another setting. function AIParameterSettingList:copy(setting) + if self.data.isCopyValueDisabled then + return + end + if self.data.incremental and self.data.incremental ~= 1 then self:setFloatValue(setting.values[setting.current], nil, true) else diff --git a/scripts/ai/parameters/CpAIParameterFillTypeSelection.lua b/scripts/ai/parameters/CpAIParameterFillTypeSelection.lua new file mode 100644 index 000000000..86ff5ca1b --- /dev/null +++ b/scripts/ai/parameters/CpAIParameterFillTypeSelection.lua @@ -0,0 +1,159 @@ +---@class CpAIParameterFillTypeSetting : AIParameterSetting +CpAIParameterFillTypeSetting = CpObject(AIParameterSetting) +AIParameterType.TEXT_BUTTON = 99 +function CpAIParameterFillTypeSetting:init(data, vehicle, class) + AIParameterSetting.init(self, data, vehicle, class) + self.guiParameterType = AIParameterType.TEXT_BUTTON --- For the giants gui element. + self:initFromData(data, vehicle, class) + --- Use this hack to load the setting without the need for changing every setting behavior. + local filePath = Utils.getFilename("config/jobParameters/FillTypeSelectionParameterSetup.xml", + g_Courseplay.BASE_DIRECTORY) + local childrenData = { + generateFillTypes = class.generateFillTypes + } + CpSettingsUtil.loadSettingsFromSetup(childrenData, filePath) + + --- Child parameters + ---@type AIParameterSettingList + self.fillType = childrenData.fillType:clone(vehicle, class) + self.fillType:setParent(self) + ---@type AIParameterSettingList + self.maxFillLevel = childrenData.maxFillLevel:clone(vehicle, class) + self.maxFillLevel:setParent(self) + ---@type AIParameterSettingList + self.minFillLevel = childrenData.minFillLevel:clone(vehicle, class) + self.minFillLevel:setParent(self) + ---@type AIParameterSettingList + self.counter = childrenData.counter:clone(vehicle, class) + self.counter:setParent(self) + self.currentCounterValue = 0 +end + +function CpAIParameterFillTypeSetting:saveToXMLFile(xmlFile, key, usedModNames) + self.fillType:saveToXMLFile(xmlFile, key..".fillType", usedModNames) + self.maxFillLevel:saveToXMLFile(xmlFile, key..".maxFillLevel", usedModNames) + self.minFillLevel:saveToXMLFile(xmlFile, key..".minFillLevel", usedModNames) + self.counter:saveToXMLFile(xmlFile, key..".counter", usedModNames) +end + +function CpAIParameterFillTypeSetting:loadFromXMLFile(xmlFile, key) + self.fillType:loadFromXMLFile(xmlFile, key..".fillType") + self.maxFillLevel:loadFromXMLFile(xmlFile, key..".maxFillLevel") + self.minFillLevel:loadFromXMLFile(xmlFile, key..".minFillLevel") + self.counter:loadFromXMLFile(xmlFile, key..".counter") +end + +function CpAIParameterFillTypeSetting:readStream(streamId, connection) + self.fillType:readStream(streamId, connection) + self.maxFillLevel:readStream(streamId, connection) + self.minFillLevel:readStream(streamId, connection) + self.counter:readStream(streamId, connection) + self.isSynchronized = true +end + +function CpAIParameterFillTypeSetting:writeStream(streamId, connection) + self.fillType:writeStream(streamId, connection) + self.maxFillLevel:writeStream(streamId, connection) + self.minFillLevel:writeStream(streamId, connection) + self.counter:writeStream(streamId, connection) +end + +function CpAIParameterFillTypeSetting:refresh() + self.fillType:refresh() + self.maxFillLevel:refresh() + self.minFillLevel:refresh() + self.counter:refresh() +end + +function CpAIParameterFillTypeSetting:clone(...) + return CpAIParameterFillTypeSetting(self.data,...) +end + +function CpAIParameterFillTypeSetting:copy(otherSetting) + if self.data.isCopyValueDisabled then + return + end + self.fillType:copy(otherSetting.fillType) + self.maxFillLevel:copy(otherSetting.maxFillLevel) + self.minFillLevel:copy(otherSetting.minFillLevel) + self.counter:copy(otherSetting.counter) +end + +function CpAIParameterFillTypeSetting:resetToLoadedValue() + self.fillType:resetToLoadedValue() + self.maxFillLevel:resetToLoadedValue() + self.minFillLevel:resetToLoadedValue() + self.counter:resetToLoadedValue() +end + +function CpAIParameterFillTypeSetting:bindSettingsToGui(lambda, ...) + lambda(self, self.fillType, self.maxFillLevel, self.minFillLevel, self.counter, ...) +end + +function CpAIParameterFillTypeSetting:getNumberOfItemsInSection(list, section) + return self.fillType:getNumberOfElements() +end + +function CpAIParameterFillTypeSetting:getTitleForSectionHeader(list, section) + return nil +end + +function CpAIParameterFillTypeSetting:populateCellForItemInSection(list, section, index, cell) + cell:getAttribute("title"):setText(self.fillType:getTextByIndex(index)) + local fillType = g_fillTypeManager:getFillTypeByIndex(self.fillType:getValueByIndex(index)) + if fillType then + cell:getAttribute("icon"):setImageFilename(fillType.hudOverlayFilename) + end + cell:getAttribute("icon"):setVisible(fillType ~= nil) + cell.onClickCallback = function () + self.fillType:setValue(self.fillType:getValueByIndex(index)) + end +end + +function CpAIParameterFillTypeSetting:onListSelectionChanged(list, section, index) + self.fillType:setValue(self.fillType:getValueByIndex(index)) +end + +function CpAIParameterFillTypeSetting:getString() + if self.fillType:getValue() < 0 then + return self.fillType:getString() + end + local string = string.format("%s | %s | %s", + self.fillType:getString(), + self.minFillLevel:getString(), + self.maxFillLevel:getString()) + + if not self.counter:getIsDisabled() then + string = string.format("%s | %s", string, self.counter:getString()) + end + return string +end + +function CpAIParameterFillTypeSetting:__tostring() + return string.format("CpAIParameterFillTypeSetting(fillType: %s, maxFillLevel: %s, minFillLevel: %s, counter: %s)", + tostring(self.fillType), tostring(self.maxFillLevel), tostring(self.minFillLevel), tostring(self.counter)) +end + +function CpAIParameterFillTypeSetting:getIsValid() + return self.isValid +end + +function CpAIParameterFillTypeSetting:applyCounter(reset) + self.currentCounterValue = self.currentCounterValue + 1 + if reset then + self.currentCounterValue = 0 + end +end + +function CpAIParameterFillTypeSetting:getCounter() + return self.currentCounterValue +end + +function CpAIParameterFillTypeSetting:getIsCounterValid() + return self.currentCounterValue <= self.counter:getValue() +end + +function CpAIParameterFillTypeSetting:getCustomIconFilename() + local fillType = g_fillTypeManager:getFillTypeByIndex(self.fillType:getValue()) + return fillType and fillType.hudOverlayFilename +end \ No newline at end of file diff --git a/scripts/ai/parameters/CpAIParameterGroup.lua b/scripts/ai/parameters/CpAIParameterGroup.lua new file mode 100644 index 000000000..bf3625c2a --- /dev/null +++ b/scripts/ai/parameters/CpAIParameterGroup.lua @@ -0,0 +1,37 @@ +---@class CpAIParameterGroup +CpAIParameterGroup = CpObject() +function CpAIParameterGroup:init(class, data) + self.class = class + self.data = data + self.parameters = {} +end + +---@return string +function CpAIParameterGroup:getTitle() + return g_i18n:getText(self.data.title) +end + +---@param parameter AIParameterSetting +function CpAIParameterGroup:addParameter(parameter) + table.insert(self.parameters, parameter) +end + +---@return AIParameterSetting[] +function CpAIParameterGroup:getParameters() + return self.parameters +end + +function CpAIParameterGroup:getIsDisabled() + return self:getCallback(self.data.isDisabledFunc) +end + +function CpAIParameterGroup:getIsVisible() + return self.data.isVisibleFunc == nil or self:getCallback(self.data.isVisibleFunc) +end + +--- Gets the result from a class callback. +function CpAIParameterGroup:getCallback(callbackStr, ...) + if callbackStr and self.class and self.class[callbackStr] then + return self.class[callbackStr](self.class, self, ...) + end +end \ No newline at end of file diff --git a/scripts/ai/parameters/CpAIParameterPositionAngle.lua b/scripts/ai/parameters/CpAIParameterPositionAngle.lua index 60c886e06..d916dfe83 100644 --- a/scripts/ai/parameters/CpAIParameterPositionAngle.lua +++ b/scripts/ai/parameters/CpAIParameterPositionAngle.lua @@ -78,6 +78,9 @@ function CpAIParameterPosition:clone(...) end function CpAIParameterPosition:copy(setting) + if self.data.isCopyValueDisabled then + return + end self.x = setting.x self.z = setting.z end @@ -138,6 +141,9 @@ function CpAIParameterPositionAngle:clone(...) end function CpAIParameterPositionAngle:copy(setting) + if self.data.isCopyValueDisabled then + return + end CpAIParameterPosition.copy(self, setting) self.angle = setting.angle self.snappingAngle = setting.snappingAngle diff --git a/scripts/ai/parameters/CpAIParameterTargetPoint.lua b/scripts/ai/parameters/CpAIParameterTargetPoint.lua new file mode 100644 index 000000000..e085fb14e --- /dev/null +++ b/scripts/ai/parameters/CpAIParameterTargetPoint.lua @@ -0,0 +1,67 @@ +--- Parameter to selected an unloading station. +---@class CpAIParameterTargetPoint : AIParameterSetting +CpAIParameterTargetPoint = CpObject(AIParameterSetting) + +function CpAIParameterTargetPoint:init(data, vehicle, class) + AIParameterSetting.init(self, data, vehicle, class) + self:initFromData(data, vehicle, class) + self.guiParameterType = AIParameterType.TEXT_BUTTON + self.uniqueID = -1 + return self +end + +function CpAIParameterTargetPoint:clone(...) + return CpAIParameterTargetPoint(self.data,...) +end + +function CpAIParameterTargetPoint:saveToXMLFile(xmlFile, key, usedModNames) + xmlFile:setInt(key .. "#currentValue", self.uniqueID) +end + +function CpAIParameterTargetPoint:loadFromXMLFile(xmlFile, key) + self.uniqueID = xmlFile:getInt(key .. "#currentValue", self.uniqueID) +end + +function CpAIParameterTargetPoint:getString() + local target = g_graph:getTargetByUniqueID(self.uniqueID) + return target and target:getName() or "???" +end + +function CpAIParameterTargetPoint:getValue() + return self.uniqueID +end + +-- function CpAIParameterTargetPoint:getIsDisabled() +-- return AIParameterSetting.getIsDisabled(self) or +-- g_graph:getTargetByUniqueID(self.uniqueID) == nil +-- end + +function CpAIParameterTargetPoint:setValue(uniqueID, noEventSend) + self.uniqueID = uniqueID + if uniqueID ~= self.uniqueID then + self:onChange() + end +end + +function CpAIParameterTargetPoint:copy(setting) + if self.data.isCopyValueDisabled then + return + end + self:setValue(setting:getValue()) +end + +function CpAIParameterTargetPoint:onChange() + self:raiseCallback(self.data.callbacks.onChangeCallbackStr) +end + +function CpAIParameterTargetPoint:refresh() + local target = g_graph:getTargetByUniqueID(self.uniqueID) + if not target then + self:setValue(-1) + end +end + +function CpAIParameterTargetPoint:__tostring() + return string.format("CpAIParameterTargetPoint(name=%s, value=%s, text=%s)", + self.name, tostring(self:getValue()), self:getString()) +end \ No newline at end of file diff --git a/scripts/ai/strategies/AIDriveStrategyCombineCourse.lua b/scripts/ai/strategies/AIDriveStrategyCombineCourse.lua index edd0742e6..f3bd7e1c2 100644 --- a/scripts/ai/strategies/AIDriveStrategyCombineCourse.lua +++ b/scripts/ai/strategies/AIDriveStrategyCombineCourse.lua @@ -312,7 +312,7 @@ function AIDriveStrategyCombineCourse:getDriveData(dt, vX, vY, vZ) return AIDriveStrategyFieldWorkCourse.getDriveData(self, dt, vX, vY, vZ) end -function AIDriveStrategyCombineCourse:updateFieldworkOffset(course) +function AIDriveStrategyCombineCourse:updateCourseOffset(course) if self.state == self.states.UNLOADING_ON_FIELD and self:isUnloadStateOneOf(self.selfUnloadStates) then -- do not apply fieldwork offset when not doing fieldwork course:setOffset((self.aiOffsetX or 0) + (self.tightTurnOffset or 0), (self.aiOffsetZ or 0)) diff --git a/scripts/ai/strategies/AIDriveStrategyCourse.lua b/scripts/ai/strategies/AIDriveStrategyCourse.lua index c32119a14..31362256e 100644 --- a/scripts/ai/strategies/AIDriveStrategyCourse.lua +++ b/scripts/ai/strategies/AIDriveStrategyCourse.lua @@ -677,7 +677,7 @@ end --- This is to set the offsets on the course at start, or update those values --- if the user changed them during the run or the AI driver wants to add an offset -function AIDriveStrategyCourse:updateFieldworkOffset(course) +function AIDriveStrategyCourse:updateCourseOffset(course) course:setOffset(self.settings.toolOffsetX:getValue() + (self.aiOffsetX or 0) + (self.tightTurnOffset or 0), (self.aiOffsetZ or 0)) end diff --git a/scripts/ai/strategies/AIDriveStrategyDriveToFieldWorkStart.lua b/scripts/ai/strategies/AIDriveStrategyDriveToFieldWorkStart.lua index 67f8c7191..9aca44087 100644 --- a/scripts/ai/strategies/AIDriveStrategyDriveToFieldWorkStart.lua +++ b/scripts/ai/strategies/AIDriveStrategyDriveToFieldWorkStart.lua @@ -65,7 +65,7 @@ function AIDriveStrategyDriveToFieldWorkStart:initializeImplementControllers(veh end function AIDriveStrategyDriveToFieldWorkStart:start(course, startIx, jobParameters) - self:updateFieldworkOffset(course) + self:updateCourseOffset(course) --- Saves the course start position, so it can be given to the job instance. local x, _, z = course:getWaypointPosition(startIx) self.startPosition = {x = x, z = z} diff --git a/scripts/ai/strategies/AIDriveStrategyFieldWorkCourse.lua b/scripts/ai/strategies/AIDriveStrategyFieldWorkCourse.lua index a38027132..b308011bf 100644 --- a/scripts/ai/strategies/AIDriveStrategyFieldWorkCourse.lua +++ b/scripts/ai/strategies/AIDriveStrategyFieldWorkCourse.lua @@ -59,7 +59,7 @@ end --- the most we need is an alignment course to lower the implements function AIDriveStrategyFieldWorkCourse:start(course, startIx, jobParameters) self:showAllInfo('Starting field work at waypoint %d', startIx) - self:updateFieldworkOffset(course) + self:updateCourseOffset(course) self.fieldWorkCourse = course self.fieldWorkCourse:setCurrentWaypointIx(startIx) self.remainingTime = CpRemainingTime(self.vehicle, course, startIx) @@ -148,7 +148,7 @@ end --- This is the interface to the Giant's AIFieldWorker specialization, telling it the direction and speed function AIDriveStrategyFieldWorkCourse:getDriveData(dt, vX, vY, vZ) - self:updateFieldworkOffset(self.course) + self:updateCourseOffset(self.course) self:updateLowFrequencyImplementControllers() Markers.refreshMarkerNodes(self.vehicle, self.measuredBackDistance) diff --git a/scripts/ai/strategies/AIDriveStrategyFindBales.lua b/scripts/ai/strategies/AIDriveStrategyFindBales.lua index f694e43f0..069ee5722 100644 --- a/scripts/ai/strategies/AIDriveStrategyFindBales.lua +++ b/scripts/ai/strategies/AIDriveStrategyFindBales.lua @@ -340,23 +340,36 @@ end --- Finishes the job with the correct stop reason, as --- the correct reason is needed for a possible AD takeover. function AIDriveStrategyFindBales:finishJob() + local stopClosure = function (message) + self.vehicle:stopCurrentAIJob(message) + end + if self.jobParameters.unloadTargetPointEnabled:getValue() then + stopClosure = function (message) + if self:hasBalesLoaded() then + self.currentTask:skip() + else + self.vehicle:stopCurrentAIJob(message) + end + end + end + if self:areBaleLoadersFull() then self:debug('All the bale loaders are full, so stopping the job.') - self.vehicle:stopCurrentAIJob(AIMessageErrorIsFull.new()) + stopClosure(AIMessageErrorIsFull.new()) elseif self:hasBalesLoaded() then if self.baleLoaderController and self.baleLoaderController:isChangingBaleSize() then self:debug('There really are no more bales on the field, so stopping the job') self.vehicle:stopCurrentAIJob(AIMessageSuccessFinishedJob.new()) else self:debug('No more bales found on the field, so stopping the job and sending the loader to unload the bales.') - self.vehicle:stopCurrentAIJob(AIMessageErrorIsFull.new()) + stopClosure(AIMessageErrorIsFull.new()) end elseif self.baleLoader and self.wrongWrapTypeFound then self:debug('Only bales with a wrong wrap type are left on the field.') - self.vehicle:stopCurrentAIJob(AIMessageErrorWrongBaleWrapType.new()) + stopClosure(AIMessageErrorWrongBaleWrapType.new()) else self:debug('No more bales left on the field and no bales are loader and so on ..') - self.vehicle:stopCurrentAIJob(AIMessageSuccessFinishedJob.new()) + stopClosure(AIMessageSuccessFinishedJob.new()) end end @@ -624,9 +637,11 @@ function AIDriveStrategyFindBales:isPositionOk() or CpMathUtil.isWithinDistanceToPolygon(self.vehicle:cpGetFieldPolygon(), x, z, 2 * AIDriveStrategyFindBales.minStartDistanceToField) then --- Goal position marker set in the ai menu rotated by 180 degree. - self.invertedStartPositionMarkerNode = CpUtil.createNode("Inverted Start position marker", - x, z, angle + math.pi) - self:debug("Valid goal position marker was set.") + if not self.jobParameters.unloadTargetPointEnabled:getValue() then + self.invertedStartPositionMarkerNode = CpUtil.createNode("Inverted Start position marker", + x, z, angle + math.pi) + self:debug("Valid goal position marker was set.") + end else self:debug("Start position is too far away from the field for a valid goal position!") end diff --git a/scripts/ai/strategies/AIDriveStrategyPlowCourse.lua b/scripts/ai/strategies/AIDriveStrategyPlowCourse.lua index a143f5882..9d187f7f3 100644 --- a/scripts/ai/strategies/AIDriveStrategyPlowCourse.lua +++ b/scripts/ai/strategies/AIDriveStrategyPlowCourse.lua @@ -190,7 +190,7 @@ function AIDriveStrategyPlowCourse:getTurnEndSideOffset() end end -function AIDriveStrategyPlowCourse:updateFieldworkOffset(course) +function AIDriveStrategyPlowCourse:updateCourseOffset(course) --- Ignore the tool offset setting. course:setOffset((self.aiOffsetX or 0), (self.aiOffsetZ or 0)) end diff --git a/scripts/ai/strategies/AIDriveStrategyStreetDriveLoading.lua b/scripts/ai/strategies/AIDriveStrategyStreetDriveLoading.lua new file mode 100644 index 000000000..42a3a4114 --- /dev/null +++ b/scripts/ai/strategies/AIDriveStrategyStreetDriveLoading.lua @@ -0,0 +1,200 @@ +---@class AIDriveStrategyStreetDriveLoading : AIDriveStrategyStreetDriveToPoint +AIDriveStrategyStreetDriveLoading = CpObject(AIDriveStrategyStreetDriveToPoint) + +AIDriveStrategyStreetDriveLoading.myStates = { + +} +AIDriveStrategyStreetDriveLoading.COURSE_EXTENSION = 2 + +function AIDriveStrategyStreetDriveLoading:init(task, job) + AIDriveStrategyStreetDriveToPoint.init(self, task, job) + AIDriveStrategyCourse.initStates(self, AIDriveStrategyStreetDriveLoading.myStates) + +end + +function AIDriveStrategyStreetDriveLoading:setAllStaticParameters() + AIDriveStrategyStreetDriveToPoint.setAllStaticParameters(self) + + ---@type CpAIParameterFillTypeSetting[] + self.fillTypeSettings = self.jobParameters:getFillTypeSelectionSettings() + self.hasCourseEndReached = false + ---@type table + self.fillTypeSettingsLoaded = {} + + self.isLoading = false + self.currentLoadTrigger = nil +end + +function AIDriveStrategyStreetDriveLoading:initializeImplementControllers(vehicle) + AIDriveStrategyStreetDriveToPoint.initializeImplementControllers(self, vehicle) + self:addImplementController(vehicle, CoverController, Cover, {}) +end + +function AIDriveStrategyStreetDriveLoading:isCoverOpeningAllowed() + local course = self.ppc:getCourse() + local length = AIUtil.getLength(self.vehicle) + return course and course:isCloseToLastWaypoint(length * 1.5) +end + +function AIDriveStrategyStreetDriveLoading:drivingCourse() + local course = self.ppc:getCourse() + local length = AIUtil.getLength(self.vehicle) + if course:isCloseToLastWaypoint(length * 3 + self.COURSE_EXTENSION) then + self:setMaxSpeed(self.settings.turnSpeed:getValue()) + self:debugSparse("Slowing down close to the loading point.") + end + if course:isCloseToLastWaypoint(length * 1.5 + 2 * self.COURSE_EXTENSION) then + self:updateLoading() + end + if self.hasCourseEndReached then + local missingFillTypes = {} + local fillLevels = FillLevelUtil.getAllFillLevels(self.vehicle) + --- Check min fill levels + for _, fillTypeSetting in pairs(self.fillTypeSettings) do + local fillType = fillTypeSetting.fillType:getValue() + local data = fillLevels[fillType] + if data ~= nil then + if (100 * data.allowedFillLevel / data.allowedCapacity) + <= fillTypeSetting.minFillLevel:getValue() then + missingFillTypes[fillType] = true + end + end + end + if not next(missingFillTypes) then + for setting, _ in pairs(self.fillTypeSettingsLoaded) do + setting:applyCounter() + end + self:debug("No more missing fill types, so continuing ..") + if not self.isLoading then + self:setCurrentTaskFinished() + end + end + self:setMaxSpeed(0) + end +end + +function AIDriveStrategyStreetDriveLoading:onCourseEndReached() + --- TODO + self.hasCourseEndReached = true +end + +function AIDriveStrategyStreetDriveLoading:getTargetExtension() + local length = AIUtil.getLength(self.vehicle) + return length + self.COURSE_EXTENSION +end + +function AIDriveStrategyStreetDriveLoading:updateLoading() + local missingFillTypes = {} + local fillLevels = FillLevelUtil.getAllFillLevels(self.vehicle) + --- Check min fill levels + for _, fillTypeSetting in pairs(self.fillTypeSettings) do + local fillType = fillTypeSetting.fillType:getValue() + local data = fillLevels[fillType] + if fillType > FillType.UNKNOWN and data ~= nil and data.allowedCapacity > 0 then + if (100 * data.allowedFillLevel / data.allowedCapacity) + <= fillTypeSetting.minFillLevel:getValue() then + missingFillTypes[fillType] = true + end + end + end + local validTriggers, isLoading = {}, false + if not self.isLoading then + for _, loadTrigger in pairs(g_triggerManager:getLoadTriggers()) do + --- Gathering all possible triggers + local trigger = loadTrigger:getTrigger() + if trigger:getIsFillableObjectAvailable() and not trigger.isLoading then + if trigger.validFillableObject and trigger.validFillableObject.rootVehicle == self.vehicle then + table.insert(validTriggers, { + cpTrigger = loadTrigger, + trigger = trigger, + object = trigger.validFillableObject, + fillUnitIndex = trigger.validFillableFillUnitIndex, + fillLevels = trigger.source:getAllFillLevels(g_currentMission:getFarmId()), + fillTypes = trigger.fillTypes + }) + end + end + end + --- Checks if starting is possible? + for _, triggerData in ipairs(validTriggers) do + for _, fillTypeSetting in pairs(self.fillTypeSettings) do + local fillType = fillTypeSetting.fillType:getValue() + local fillLevel = triggerData.fillLevels[fillType] + if fillType > FillType.UNKNOWN and (triggerData.fillTypes == nil or triggerData.fillTypes[fillType]) and fillLevel then + --- Fill level was found for given fill type. + if triggerData.object:getFillUnitAllowsFillType(triggerData.fillUnitIndex, fillType) then + if fillTypeSetting.maxFillLevel:getValue() > + triggerData.object:getFillUnitFillLevelPercentage(triggerData.fillUnitIndex) * 100 then + --- Loading is allowed + self:setMaxSpeed(0) + triggerData.trigger:onFillTypeSelection(fillType) + self.fillTypeSettingsLoaded[fillTypeSetting] = true + self.isLoading = true + self.currentLoadTrigger = triggerData.cpTrigger + self:debug("Starting to load %s", + g_fillTypeManager:getFillTypeNameByIndex(fillType)) + return + end + end + if missingFillTypes[fillType] then + self:setMaxSpeed(0) + self:debugSparse("Waiting at trigger for fill type: %s", + g_fillTypeManager:getFillTypeNameByIndex(fillType)) + end + end + end + end + else + local trigger = self.currentLoadTrigger:getTrigger() + local fillType = trigger.selectedFillType + local object = trigger.currentFillableObject + local fillUnitIndex = trigger.fillUnitIndex + --- Checks if stopping is necessary? + for _, fillTypeSetting in pairs(self.fillTypeSettings) do + if fillTypeSetting.fillType:getValue() == fillType then + if fillTypeSetting.maxFillLevel:getValue() < + object:getFillUnitFillLevelPercentage(fillUnitIndex) * 100 then + --- Stops the loading + self:debug("Finished loading of %s", + g_fillTypeManager:getFillTypeNameByIndex(fillType)) + self:setMaxSpeed(0) + trigger:setIsLoading(false) + self.isLoading = false + self.currentLoadTrigger = nil + return + end + end + end + self:setMaxSpeed(0) + self:debugSparse("Currently loading %s", + g_fillTypeManager:getFillTypeNameByIndex(trigger.currentFillType)) + return + end +end + +function AIDriveStrategyStreetDriveLoading:onLoadingFinished(trigger) + self.isLoading = false + self.currentLoadTrigger = nil +end + +local function loadingAtLoadTriggerHasStopped(trigger) + if trigger.isLoading then + if trigger.currentFillableObject + and trigger.currentFillableObject.rootVehicle.getIsCpActive + and trigger.currentFillableObject.rootVehicle:getIsCpActive() then + local strategy = trigger.currentFillableObject.rootVehicle:getCpDriveStrategy() + if strategy and strategy.onLoadingFinished then + strategy:onLoadingFinished(trigger) + end + end + end +end +LoadTrigger.stopLoading = Utils.prependedFunction(LoadTrigger.stopLoading, loadingAtLoadTriggerHasStopped) + +local function enableLoadingAtLoadTriggers(trigger, superFunc, fillableObject) + if fillableObject and fillableObject.rootVehicle:getIsAIActive() then + return true + end + superFunc(trigger, fillableObject) +end +LoadTrigger.getAllowsActivation = Utils.overwrittenFunction(LoadTrigger.getAllowsActivation, enableLoadingAtLoadTriggers) \ No newline at end of file diff --git a/scripts/ai/strategies/AIDriveStrategyStreetDriveToPoint.lua b/scripts/ai/strategies/AIDriveStrategyStreetDriveToPoint.lua new file mode 100644 index 000000000..17167e35f --- /dev/null +++ b/scripts/ai/strategies/AIDriveStrategyStreetDriveToPoint.lua @@ -0,0 +1,263 @@ +---@class AIDriveStrategyStreetDriveToPoint : AIDriveStrategyCourse +AIDriveStrategyStreetDriveToPoint = CpObject(AIDriveStrategyCourse) + +AIDriveStrategyStreetDriveToPoint.myStates = { + PREPARE_TO_DRIVE = {}, + PREPARE_FINISHED = {}, + DRIVING_TO_COURSE_START = {}, + DRIVING_COURSE = {} +} +function AIDriveStrategyStreetDriveToPoint:init(task, job) + AIDriveStrategyCourse.init(self, task, job) + AIDriveStrategyCourse.initStates(self, AIDriveStrategyStreetDriveToPoint.myStates) + self.state = self.states.INITIAL + self.prepareState = self.states.PREPARE_TO_DRIVE + self.debugChannel = CpDebug.DBG_FIELDWORK + self.prepareTimeout = 0 + self.emergencyBrake = CpTemporaryObject(true) +end + +function AIDriveStrategyStreetDriveToPoint:setAllStaticParameters() + AIDriveStrategyCourse.setAllStaticParameters(self) + self.turningRadius = AIUtil.getTurningRadius(self.vehicle) + self:setFrontAndBackMarkers() +end + +function AIDriveStrategyStreetDriveToPoint:initializeImplementControllers(vehicle) + self:addImplementController(vehicle, MotorController, Motorized, {}) + self:addImplementController(vehicle, WearableController, Wearable, {}) +end + +---@param target GraphTarget +function AIDriveStrategyStreetDriveToPoint:setTarget(target) + self.target = target +end + +function AIDriveStrategyStreetDriveToPoint:isGeneratedCourseNeeded() + return false +end + +function AIDriveStrategyStreetDriveToPoint:getProximitySensorWidth() + return AIUtil.getWidth(self.vehicle) * 1.2 +end + +function AIDriveStrategyStreetDriveToPoint:startWithoutCourse(jobParameters) + -- to always have a valid course (for the traffic conflict detector mainly) + local course = Course.createStraightForwardCourse(self.vehicle, 25) + self:startCourse(course, 1) +end + +function AIDriveStrategyStreetDriveToPoint:update(dt) + AIDriveStrategyCourse.update(self, dt) + self:updateImplementControllers(dt) + if self.ppc:getCourse() and CpDebug:isChannelActive(CpDebug.DBG_FIELDWORK, self.vehicle) then + self.ppc:getCourse():draw() + end +end + +function AIDriveStrategyStreetDriveToPoint:getDriveData(dt, vX, vY, vZ) + self:updateLowFrequencyImplementControllers() + self:updateLowFrequencyPathfinder() + + local moveForwards = not self.ppc:isReversing() + local gx, gz, _ + + if not moveForwards then + local maxSpeed + gx, gz, maxSpeed = self:getReverseDriveData() + self:setMaxSpeed(maxSpeed) + else + gx, _, gz = self.ppc:getGoalPointPosition() + end + if self.state == self.states.INITIAL then + self:setMaxSpeed(0) + if self.target then + self.vehicle:prepareForAIDriving() + local targetVector = self.target:toVector() + local extension = self:getTargetExtension() + if extension > 0 then + --- TODO move this to the graph pathfiner + --- Basically we want to move the goal node + --- by X meter ahead of the inital target. + local p = self.target:getPoint() + local seg = p:getParentNode() + for _, n in ipairs(seg:getChildNodesBetweenIndex(p:getID(), seg:getNumChildNodes())) do + targetVector = n:toVector() + if p:getDistance2DToPoint(n) > extension then + break + end + end + end + local goal = State3D(targetVector.x, targetVector.y, 0, 0) + local context = PathfinderContext(self.vehicle) + self.pathfinderController:findPathOnStreet(context, goal, 0) + self.pathfinderController:registerListeners(self, self.onStreetPathfindingDone) + self.state = self.states.WAITING_FOR_PATHFINDER + else + self:debug("Skipping drive to start point strategy, as no course was given!") + self:setCurrentTaskFinished() + end + elseif self.state == self.states.DRIVING_TO_COURSE_START then + self:setMaxSpeed(self.settings.streetSpeed:getValue()) + elseif self.state == self.states.DRIVING_COURSE then + self:drivingCourse() + self:setMaxSpeed(self.settings.streetSpeed:getValue()) + end + + if self.prepareState == self.states.PREPARE_TO_DRIVE then + self:setMaxSpeed(0) + local isReadyToDrive, blockingVehicle = self.vehicle:getIsAIReadyToDrive() + if isReadyToDrive then + self.prepareState = self.states.PREPARE_FINISHED + self:debug('Ready to drive') + else + self:debugSparse('Not ready to drive because of %s, preparing ...', CpUtil.getName(blockingVehicle)) + if not self.vehicle:getIsAIPreparingToDrive() then + self.prepareTimeout = self.prepareTimeout + dt + if 2000 < self.prepareTimeout then + self:debug('Timeout preparing, continue anyway') + self.prepareState = self.states.PREPARE_FINISHED + end + end + end + end + self:limitSpeed() + self:checkProximitySensors(moveForwards) + return gx, gz, moveForwards, self.maxSpeed, 100 +end + +-- TODO: This should go into a SpeedController? +function AIDriveStrategyStreetDriveToPoint:limitSpeed() + AIDriveStrategyCourse.limitSpeed(self) + if not self.lastSpeedCheck or (self.lastSpeedCheck and getTimeSec() - self.lastSpeedCheck > 1) then + self.lastSpeedCheck = getTimeSec() + local r = self.course:getMinRadiusWithinDistance(self.ppc:getRelevantWaypointIx(), 20) + if r then + -- we do not slow down over 50 m radius, but slow down to turning speed at the turningRadius, + -- proportionally in between + local slowDownFactor = math.min(50, math.max(self.turningRadius, r - self.turningRadius)) / 50 + local oldLimitedSpeed = self.limitedSpeed or 0 + self.limitedSpeed = self.settings.turnSpeed:getValue() + + (self.settings.streetSpeed:getValue() - self.settings.turnSpeed:getValue()) * slowDownFactor + if math.abs(self.limitedSpeed - oldLimitedSpeed) > 0.75 then + self:debug('Limiting speed to %.2f (r=%.2f, slowDownFactor=%.2f)', self.limitedSpeed, r, slowDownFactor) + end + end + end + if self.limitedSpeed then + self:setMaxSpeed(self.limitedSpeed) + end +end + +function AIDriveStrategyStreetDriveToPoint:calculateTightTurnOffset() + self.tightTurnOffset = AIUtil.calculateTightTurnOffset(self.vehicle, self.turningRadius, self.course, + self.tightTurnOffset) +end + +function AIDriveStrategyStreetDriveToPoint:drivingCourse() + --- override +end + +function AIDriveStrategyStreetDriveToPoint:onCourseEndReached() + self:setCurrentTaskFinished() +end + +----------------------------------------------------------------------------------------------------------------------- +--- Event listeners +----------------------------------------------------------------------------------------------------------------------- +---@param course Course +function AIDriveStrategyStreetDriveToPoint:onWaypointPassed(ix, course) + if course:isLastWaypointIx(ix) then + if self.state == self.states.DRIVING_TO_COURSE_START then + local course, ix = self:getRememberedCourseAndIx() + self:startDrivingCourse(course, ix) + elseif self.state == self.states.DRIVING_COURSE then + self:onCourseEndReached() + end + end +end + +function AIDriveStrategyStreetDriveToPoint:onWaypointChange(ix, course) + self:calculateTightTurnOffset() +end + +-------------------------------------------------------- +--- Pathfinding +-------------------------------------------------------- + +---@param course Course +function AIDriveStrategyStreetDriveToPoint:isPathFindingNeeded(course) + local ixClosest, distanceClosest, ixClosestRightDirection, distanceClosestRightDirection = course:getNearestWaypoints(self.vehicle:getAIDirectionNode()) + if distanceClosestRightDirection - distanceClosest > 25 then + return true, ixClosest + end + return distanceClosestRightDirection > 10, ixClosestRightDirection +end + +--- Pathfinding has finished +---@param controller PathfinderController +---@param success boolean +---@param course Course|nil +---@param goalNodeInvalid boolean|nil +function AIDriveStrategyStreetDriveToPoint:onPathfindingFinished(controller, + success, course, goalNodeInvalid) + if not success then + self:debug('Pathfinding failed, giving up!') + self.vehicle:stopCurrentAIJob(AIMessageCpErrorNoPathFound.new()) + return + end + if self.state == self.states.DRIVING_TO_COURSE_START then + self:startCourse(course, 1) + end +end + +--- Pathfinding failed, but a retry attempt is leftover. +---@param controller PathfinderController +---@param lastContext PathfinderContext +---@param wasLastRetry boolean +---@param currentRetryAttempt number +function AIDriveStrategyStreetDriveToPoint:onPathfindingRetry(controller, + lastContext, wasLastRetry, currentRetryAttempt) + --- TODO: Think of possible points of failures, that could be adjusted here. + --- Maybe a small reverse course might help to avoid a deadlock + --- after one pathfinder failure based on proximity sensor data and so on .. + if self.state == self.states.DRIVING_TO_COURSE_START then + local course = self:getRememberedCourseAndIx() + controller:findPathToWaypoint(lastContext, course, + 1, 0, 0, 1) + end +end + +---@param course Course +---@param ix number +function AIDriveStrategyStreetDriveToPoint:startPathfindingToStart(course, ix) + self.state = self.states.DRIVING_TO_COURSE_START + self:rememberCourse(course, ix) + self.pathfinderController:registerListeners(self, self.onPathfindingFinished, self.onPathfindingRetry) + local context = PathfinderContext(self.vehicle):allowReverse(false):ignoreFruit():vehiclesToIgnore({ self.vehicle }) + self.pathfinderController:findPathToWaypoint(context, course, + ix, 0, 0, 1) +end + +function AIDriveStrategyStreetDriveToPoint:onStreetPathfindingDone(controller, success, course, goalNodeInvalid) + if not success then + self:debug('Pathfinding failed, giving up!') + self.vehicle:stopCurrentAIJob(AIMessageCpErrorNoPathFound.new()) + return + end + local isNeeded, ix = self:isPathFindingNeeded(course) + if isNeeded then + self:startPathfindingToStart(course, ix) + else + self:startDrivingCourse(course, ix) + end +end + +function AIDriveStrategyStreetDriveToPoint:startDrivingCourse(course, ix) + self.state = self.states.DRIVING_COURSE + self:startCourse(course, ix) +end + +function AIDriveStrategyStreetDriveToPoint:getTargetExtension() + return 0 +end \ No newline at end of file diff --git a/scripts/ai/strategies/AIDriveStrategyStreetDriveUnloading.lua b/scripts/ai/strategies/AIDriveStrategyStreetDriveUnloading.lua new file mode 100644 index 000000000..19dc1667c --- /dev/null +++ b/scripts/ai/strategies/AIDriveStrategyStreetDriveUnloading.lua @@ -0,0 +1,99 @@ +---@class AIDriveStrategyStreetDriveUnloading : AIDriveStrategyStreetDriveToPoint +AIDriveStrategyStreetDriveUnloading = CpObject(AIDriveStrategyStreetDriveToPoint) + +AIDriveStrategyStreetDriveUnloading.myStates = { + +} +AIDriveStrategyStreetDriveUnloading.COURSE_EXTENSION = 2 + +function AIDriveStrategyStreetDriveUnloading:init(task, job) + AIDriveStrategyStreetDriveToPoint.init(self, task, job) + AIDriveStrategyCourse.initStates(self, AIDriveStrategyStreetDriveUnloading.myStates) + +end + +function AIDriveStrategyStreetDriveUnloading:initializeImplementControllers(vehicle) + AIDriveStrategyStreetDriveToPoint.initializeImplementControllers(self, vehicle) + self.baleLoader = self:addImplementController(vehicle, BaleLoaderController, BaleLoader) + self.hasAutoLoader = self:addImplementController(vehicle, UniversalAutoloadController, nil, {}, "spec_universalAutoload") ~= nil +end + +function AIDriveStrategyStreetDriveUnloading:setAllStaticParameters() + AIDriveStrategyStreetDriveToPoint.setAllStaticParameters(self) + +end + +function AIDriveStrategyStreetDriveUnloading:delete() + AIDriveStrategyStreetDriveToPoint.delete(self) + if self.lastWaypointNode then + self.lastWaypointNode:destroy() + end +end + +function AIDriveStrategyStreetDriveUnloading:drivingCourse() + local course = self.ppc:getCourse() + local length = AIUtil.getLength(self.vehicle) + if course:isCloseToLastWaypoint(length * 3 + self.COURSE_EXTENSION) then + self:setMaxSpeed(self.settings.turnSpeed:getValue()) + self:debugSparse("Slowing down close to the unloading point.") + end + if course:isCloseToLastWaypoint(length * 1.5 + self.COURSE_EXTENSION) then + self:updateUnloading() + end +end + +function AIDriveStrategyStreetDriveUnloading:onCourseEndReached() + --- TODO + self:setCurrentTaskFinished() +end + +---@param course Course +---@param ix number +function AIDriveStrategyStreetDriveUnloading:startDrivingCourse(course, ix) + AIDriveStrategyStreetDriveToPoint.startDrivingCourse(self, course, ix) + -- ---@type WaypointNode + -- self.lastWaypointNode = WaypointNode("lastWaypoint") + -- self.lastWaypointNode:setToWaypoint(course, course:getNumberOfWaypoints()) +end + +function AIDriveStrategyStreetDriveUnloading:updateUnloading() + local targetDischargeNode, targetObject + local isDischarging = false + + if self.baleLoader ~= nil then + local course = self.ppc:getCourse() + if course:isCloseToLastWaypoint(1) then + if self.baleLoader:getIsAutomaticBaleUnloadingAllowed() then + self.baleLoader:startAutomaticBaleUnloading() + self:setMaxSpeed(0) + elseif self.baleLoader:getIsAutomaticBaleUnloadingInProgress() then + self:setMaxSpeed(0) + end + end + elseif self.hasAutoLoader then + --- TODO + else + local nodes, nodesToObject = AIUtil.getAllDischargeNodes(self.vehicle) + for _, node in pairs(nodes) do + isDischarging = isDischarging or nodesToObject[node]:getDischargeState() == Dischargeable.DISCHARGE_STATE_OBJECT + if node.dischargeObject ~= nil then + targetDischargeNode = node + targetObject = nodesToObject[node] + end + end + if isDischarging then + self:setMaxSpeed(0) + elseif targetObject then + self:setMaxSpeed(0) + if targetObject:getCanDischargeToObject(targetDischargeNode) then + targetObject:setCurrentDischargeNodeIndex(targetDischargeNode.index) + targetObject:setDischargeState(Dischargeable.DISCHARGE_STATE_OBJECT) + end + end + end +end + +function AIDriveStrategyStreetDriveUnloading:getTargetExtension() + local length = AIUtil.getLength(self.vehicle) + return length + self.COURSE_EXTENSION +end \ No newline at end of file diff --git a/scripts/ai/strategies/AIDriveStrategyUnloadCombine.lua b/scripts/ai/strategies/AIDriveStrategyUnloadCombine.lua index 05f8f5aad..1dfa79f13 100644 --- a/scripts/ai/strategies/AIDriveStrategyUnloadCombine.lua +++ b/scripts/ai/strategies/AIDriveStrategyUnloadCombine.lua @@ -216,6 +216,7 @@ function AIDriveStrategyUnloadCombine:init(task, job) self.movingAwayDelay = CpTemporaryObject(false) self.checkForTrailerToUnloadTo = CpTemporaryObject(true) self.unloadTargetType = self.UNLOAD_TYPES.COMBINE + self.useStreetUnload = false --- Register all active unloaders here to access them fast. AIDriveStrategyUnloadCombine.activeUnloaders[self] = self.vehicle end @@ -257,7 +258,7 @@ end function AIDriveStrategyUnloadCombine:setJobParameterValues(jobParameters) self.jobParameters = jobParameters - if jobParameters.useFieldUnload:getValue() and not jobParameters.useFieldUnload:getIsDisabled() then + if jobParameters.unloadWith:getValue() == CpCombineUnloaderJobParameters.UNLOAD_ON_FIELD then local fieldUnloadPosition = jobParameters.fieldUnloadPosition if fieldUnloadPosition ~= nil and fieldUnloadPosition.x ~= nil and fieldUnloadPosition.z ~= nil and fieldUnloadPosition.angle ~= nil then --- Valid field unload position found and allowed. @@ -266,6 +267,8 @@ function AIDriveStrategyUnloadCombine:setJobParameterValues(jobParameters) self.fieldUnloadTurnEndNode = CpUtil.createNode("Reverse field unload turn end position", fieldUnloadPosition.x, fieldUnloadPosition.z, fieldUnloadPosition.angle, nil) self.unloadTipSideID = jobParameters.unloadingTipSide:getValue() end + elseif jobParameters.unloadWith:getValue() ~= CpCombineUnloaderJobParameters.DEACTIVATED then + self.useStreetUnload = true end --- Setup the unload target mode. if jobParameters.unloadTarget:getValue() == CpCombineUnloaderJobParameters.UNLOAD_COMBINE then @@ -276,8 +279,6 @@ function AIDriveStrategyUnloadCombine:setJobParameterValues(jobParameters) self:debug("Unload target is a silo loader.") end - self.useUnloadOnField = jobParameters.useFieldUnload:getValue() and not jobParameters.useFieldUnload:getIsDisabled() - self.useGiantsUnload = jobParameters.useGiantsUnload:getValue() and not jobParameters.useGiantsUnload:getIsDisabled() end --- Gets the unload target drive strategy target. @@ -1339,7 +1340,7 @@ function AIDriveStrategyUnloadCombine:startUnloadingTrailers() end function AIDriveStrategyUnloadCombine:onTrailerFull() - if self.useGiantsUnload then + if self.useStreetUnload then self:setCurrentTaskFinished() else self.vehicle:stopCurrentAIJob(AIMessageErrorIsFull.new()) diff --git a/scripts/ai/tasks/CpAITask.lua b/scripts/ai/tasks/CpAITask.lua index 740ce6182..5ada22a29 100644 --- a/scripts/ai/tasks/CpAITask.lua +++ b/scripts/ai/tasks/CpAITask.lua @@ -25,6 +25,7 @@ function CpAITask:update(dt) end function CpAITask:start() + self:debug("started.") self.isFinished = false self.isRunning = true @@ -35,6 +36,7 @@ function CpAITask:start() end function CpAITask:skip() + self:debug("skipped.") if self.isRunning then self.isFinished = true else @@ -43,6 +45,7 @@ function CpAITask:skip() end function CpAITask:stop(wasJobStopped) + self:debug("stopped.") self.isRunning = false self.markAsFinished = false end @@ -68,10 +71,14 @@ function CpAITask:getVehicle() return self.vehicle end -function CpAITask:debug(...) +function CpAITask:__tostring() + return "CpAITask" +end + +function CpAITask:debug(str, ...) if self.vehicle then - CpUtil.debugVehicle(self.debugChannel, self.vehicle, ...) + CpUtil.debugVehicle(self.debugChannel, self.vehicle, string.format("%s: %s", tostring(self), str), ...) else - CpUtil.debugFormat(self.debugChannel, ...) + CpUtil.debugFormat(self.debugChannel, string.format("%s: %s", tostring(self), str),...) end end \ No newline at end of file diff --git a/scripts/ai/tasks/CpAITaskAttachHeader.lua b/scripts/ai/tasks/CpAITaskAttachHeader.lua index 90a8d8951..35ecdf45b 100644 --- a/scripts/ai/tasks/CpAITaskAttachHeader.lua +++ b/scripts/ai/tasks/CpAITaskAttachHeader.lua @@ -4,7 +4,6 @@ CpAITaskAttachHeader = CpObject(CpAITask) function CpAITaskAttachHeader:start() if self.isServer then - self:debug('CP Attach header task started') local strategy = AIDriveStrategyAttachHeader(self, self.job) strategy:setAIVehicle(self.vehicle, self.job:getCpJobParameters()) self.vehicle:startCpWithStrategy(strategy) @@ -14,8 +13,11 @@ end function CpAITaskAttachHeader:stop(wasJobStopped) if self.isServer then - self:debug('CP Attach header task stopped') self.vehicle:stopCpDriver(wasJobStopped) end CpAITask.stop(self) end + +function CpAITaskAttachHeader:__tostring() + return "CpAITaskBaleFinder" +end \ No newline at end of file diff --git a/scripts/ai/tasks/CpAITaskBaleFinder.lua b/scripts/ai/tasks/CpAITaskBaleFinder.lua index 7e486e6b7..d2d23fc8f 100644 --- a/scripts/ai/tasks/CpAITaskBaleFinder.lua +++ b/scripts/ai/tasks/CpAITaskBaleFinder.lua @@ -4,7 +4,6 @@ CpAITaskBaleFinder = CpObject(CpAITask) function CpAITaskBaleFinder:start() if self.isServer then - self:debug("CP bale finder task started.") local strategy = AIDriveStrategyFindBales(self, self.job) strategy:setAIVehicle(self.vehicle, self.job:getCpJobParameters()) self.vehicle:startCpWithStrategy(strategy) @@ -14,8 +13,10 @@ end function CpAITaskBaleFinder:stop(wasJobStopped) if self.isServer then - self:debug("CP bale finder task stopped.") self.vehicle:stopCpDriver(wasJobStopped) end CpAITask.stop(self) end +function CpAITaskBaleFinder:__tostring() + return "CpAITaskBaleFinder" +end \ No newline at end of file diff --git a/scripts/ai/tasks/CpAITaskBunkerSilo.lua b/scripts/ai/tasks/CpAITaskBunkerSilo.lua index 5eb8182af..226b49f85 100644 --- a/scripts/ai/tasks/CpAITaskBunkerSilo.lua +++ b/scripts/ai/tasks/CpAITaskBunkerSilo.lua @@ -13,7 +13,6 @@ end function CpAITaskBunkerSilo:start() if self.isServer then - self:debug("CP bunker silo task started.") self.vehicle:resetCpCoursesFromGui() local strategy = AIDriveStrategyBunkerSilo(self, self.job) strategy:setSilo(self.silo) @@ -25,8 +24,11 @@ end function CpAITaskBunkerSilo:stop(wasJobStopped) if self.isServer then - self:debug("CP bunker silo task stopped.") self.vehicle:stopCpDriver(wasJobStopped) end CpAITask.stop(self) end + +function CpAITaskBunkerSilo:__tostring() + return "CpAITaskBunkerSilo" +end \ No newline at end of file diff --git a/scripts/ai/tasks/CpAITaskCombineUnloader.lua b/scripts/ai/tasks/CpAITaskCombineUnloader.lua index c87545385..2e35225f8 100644 --- a/scripts/ai/tasks/CpAITaskCombineUnloader.lua +++ b/scripts/ai/tasks/CpAITaskCombineUnloader.lua @@ -4,7 +4,6 @@ CpAITaskCombineUnloader = CpObject(CpAITask) function CpAITaskCombineUnloader:start() if self.isServer then - self:debug("CP combine unloader task started.") local strategy = AIDriveStrategyUnloadCombine(self, self.job) strategy:setAIVehicle(self.vehicle, self.job:getCpJobParameters()) self.vehicle:startCpWithStrategy(strategy) @@ -14,8 +13,11 @@ end function CpAITaskCombineUnloader:stop(wasJobStopped) if self.isServer then - self:debug("CP combine unloader task stopped.") self.vehicle:stopCpDriver(wasJobStopped) end CpAITask.stop(self) end + +function CpAITaskCombineUnloader:__tostring() + return "CpAITaskCombineUnloader" +end \ No newline at end of file diff --git a/scripts/ai/tasks/CpAITaskDriveTo.lua b/scripts/ai/tasks/CpAITaskDriveTo.lua index cb259fd8f..4c337c1b3 100644 --- a/scripts/ai/tasks/CpAITaskDriveTo.lua +++ b/scripts/ai/tasks/CpAITaskDriveTo.lua @@ -4,7 +4,6 @@ CpAITaskDriveTo = CpObject(CpAITask) function CpAITaskDriveTo:start() if self.isServer then - self:debug('CP drive to task started') local strategy = AIDriveStrategyDriveToFieldWorkStart(self, self.job) strategy:setAIVehicle(self.vehicle, self.job:getCpJobParameters()) self.vehicle:startCpWithStrategy(strategy) @@ -14,8 +13,11 @@ end function CpAITaskDriveTo:stop(wasJobStopped) if self.isServer then - self:debug('CP drive to task stopped') self.vehicle:stopCpDriver(wasJobStopped) end CpAITask.stop(self) end + +function CpAITaskDriveTo:__tostring() + return "CpAITaskDriveTo" +end \ No newline at end of file diff --git a/scripts/ai/tasks/CpAITaskDriveToPoint.lua b/scripts/ai/tasks/CpAITaskDriveToPoint.lua new file mode 100644 index 000000000..a7b5d5aa8 --- /dev/null +++ b/scripts/ai/tasks/CpAITaskDriveToPoint.lua @@ -0,0 +1,33 @@ +---@class CpAITaskDriveToPoint : CpAITask +CpAITaskDriveToPoint = CpObject(CpAITask) + +function CpAITaskDriveToPoint:reset() + CpAITask.reset(self) + self.target = nil +end + +---@param target GraphTarget +function CpAITaskDriveToPoint:setTarget(target) + self.target = target +end + +function CpAITaskDriveToPoint:start() + if self.isServer then + local strategy = AIDriveStrategyStreetDriveToPoint(self, self.job) + strategy:setTarget(self.target) + strategy:setAIVehicle(self.vehicle, self.job:getCpJobParameters()) + self.vehicle:startCpWithStrategy(strategy) + end + CpAITask.start(self) +end + +function CpAITaskDriveToPoint:stop(wasJobStopped) + if self.isServer then + self.vehicle:stopCpDriver(wasJobStopped) + end + CpAITask.stop(self) +end + +function CpAITaskDriveToPoint:__tostring() + return "CpAITaskDriveToPoint" +end \ No newline at end of file diff --git a/scripts/ai/tasks/CpAITaskDriveToPointLoad.lua b/scripts/ai/tasks/CpAITaskDriveToPointLoad.lua new file mode 100644 index 000000000..c2b74776f --- /dev/null +++ b/scripts/ai/tasks/CpAITaskDriveToPointLoad.lua @@ -0,0 +1,15 @@ +---@class CpAITaskDriveToPointLoad : CpAITaskDriveToPoint +CpAITaskDriveToPointLoad = CpObject(CpAITaskDriveToPoint) +function CpAITaskDriveToPointLoad:start() + if self.isServer then + local strategy = AIDriveStrategyStreetDriveLoading(self, self.job) + strategy:setTarget(self.target) + strategy:setAIVehicle(self.vehicle, self.job:getCpJobParameters()) + self.vehicle:startCpWithStrategy(strategy) + end + CpAITask.start(self) +end + +function CpAITaskDriveToPointLoad:__tostring() + return "CpAITaskDriveToPointLoad" +end \ No newline at end of file diff --git a/scripts/ai/tasks/CpAITaskDriveToPointUnload.lua b/scripts/ai/tasks/CpAITaskDriveToPointUnload.lua new file mode 100644 index 000000000..967ee6912 --- /dev/null +++ b/scripts/ai/tasks/CpAITaskDriveToPointUnload.lua @@ -0,0 +1,15 @@ +---@class CpAITaskDriveToPointUnload : CpAITaskDriveToPoint +CpAITaskDriveToPointUnload = CpObject(CpAITaskDriveToPoint) +function CpAITaskDriveToPointUnload:start() + if self.isServer then + local strategy = AIDriveStrategyStreetDriveUnloading(self, self.job) + strategy:setTarget(self.target) + strategy:setAIVehicle(self.vehicle, self.job:getCpJobParameters()) + self.vehicle:startCpWithStrategy(strategy) + end + CpAITask.start(self) +end + +function CpAITaskDriveToPointUnload:__tostring() + return "CpAITaskDriveToPointUnload" +end \ No newline at end of file diff --git a/scripts/ai/tasks/CpAITaskFieldWork.lua b/scripts/ai/tasks/CpAITaskFieldWork.lua index dc561d87c..6d1701a73 100644 --- a/scripts/ai/tasks/CpAITaskFieldWork.lua +++ b/scripts/ai/tasks/CpAITaskFieldWork.lua @@ -125,4 +125,8 @@ function CpAITaskFieldWork:stop(wasJobStopped) self.vehicle:cpBrakeToStop() end CpAITask.stop(self, wasJobStopped) +end + +function CpAITaskFieldWork:__tostring() + return "CpAITaskFieldWork" end \ No newline at end of file diff --git a/scripts/ai/tasks/CpAITaskSiloLoader.lua b/scripts/ai/tasks/CpAITaskSiloLoader.lua index 0610792a2..6971080ee 100644 --- a/scripts/ai/tasks/CpAITaskSiloLoader.lua +++ b/scripts/ai/tasks/CpAITaskSiloLoader.lua @@ -40,3 +40,7 @@ function CpAITaskSiloLoader:stop(wasJobStopped) end CpAITask.stop(self) end + +function CpAITaskSiloLoader:__tostring() + return "CpAITaskSiloLoader" +end \ No newline at end of file diff --git a/scripts/ai/util/FillLevelUtil.lua b/scripts/ai/util/FillLevelUtil.lua index 1a4ca55f0..6fa45028a 100644 --- a/scripts/ai/util/FillLevelUtil.lua +++ b/scripts/ai/util/FillLevelUtil.lua @@ -22,29 +22,35 @@ FillLevelUtil = {} ------------------------------------------------------------------------------------------------------------------------ --- Fill Levels --------------------------------------------------------------------------------------------------------------------------- -function FillLevelUtil.getAllFillLevels(object, fillLevelInfo) - -- get own fill levels - if object.getFillUnits then - for index, fillUnit in pairs(object:getFillUnits()) do - local supportedFillTypes = object:getFillUnitSupportedFillTypes(index) - for fillType, _ in pairs(supportedFillTypes) do - local fillTypeName = g_fillTypeManager:getFillTypeNameByIndex(fillType) - --FillLevelUtil:debugSparse('%s: Fill levels: %s: %.1f/%.1f', object:getName(), fillTypeName, fillUnit.fillLevel, fillUnit.capacity) - if not fillLevelInfo[fillType] then fillLevelInfo[fillType] = {fillLevel = 0, capacity = 0} end - fillLevelInfo[fillType].fillLevel = fillLevelInfo[fillType].fillLevel + fillUnit.fillLevel - fillLevelInfo[fillType].capacity = fillLevelInfo[fillType].capacity + fillUnit.capacity - --used to check treePlanter fillLevel - local treePlanterSpec = object.spec_treePlanter - if treePlanterSpec then - fillLevelInfo[fillType].treePlanterSpec = object.spec_treePlanter + +--- Gets the fill level of all fill units sorted by the fill type. +---@param vehicle table +---@return table +function FillLevelUtil.getAllFillLevels(vehicle) + local fillLevelsByFillType = {} + for _, v in pairs(vehicle:getChildVehicles()) do + if v.getFillUnits then + for index, _ in pairs(v:getFillUnits()) do + local supportedFillTypes = v:getFillUnitSupportedFillTypes(index) + for fillType, _ in pairs(supportedFillTypes) do + if not fillLevelsByFillType[fillType] then fillLevelsByFillType[fillType] = { + fillLevel = 0, + capacity = 0, + allowedFillLevel = 0, + allowedCapacity = 0, + } + end + fillLevelsByFillType[fillType].fillLevel = fillLevelsByFillType[fillType].fillLevel + v:getFillUnitFillLevel(index) + fillLevelsByFillType[fillType].capacity = fillLevelsByFillType[fillType].capacity + v:getFillUnitCapacity(index) + if v:getFillUnitAllowsFillType(index, fillType) then + fillLevelsByFillType[fillType].allowedFillLevel = fillLevelsByFillType[fillType].allowedFillLevel + v:getFillUnitFillLevel(index) + fillLevelsByFillType[fillType].allowedCapacity = fillLevelsByFillType[fillType].allowedCapacity + v:getFillUnitCapacity(index) + end end end end end - -- collect fill levels from all attached implements recursively - for _,impl in pairs(object:getAttachedImplements()) do - FillLevelUtil.getAllFillLevels(impl.object, fillLevelInfo) - end + return fillLevelsByFillType end function FillLevelUtil.getFillTypeFromFillUnit(fillUnit) @@ -65,10 +71,7 @@ end ---@return number totalFillLevel ---@return number totalCapacity function FillLevelUtil.getTotalFillLevelAndCapacity(object) - - local fillLevelInfo = {} - FillLevelUtil.getAllFillLevels(object, fillLevelInfo) - + local fillLevelInfo = FillLevelUtil.getAllFillLevels(object) local totalFillLevel = 0 local totalCapacity = 0 for fillType, data in pairs(fillLevelInfo) do @@ -85,11 +88,8 @@ end ---@param fillTypeToFilter number fillTypeIndex to check for ---@return number totalFillLevel ---@return number totalCapacity - function FillLevelUtil.getTotalFillLevelAndCapacityForFillType(object, fillTypeToFilter) - local fillLevelInfo = {} - FillLevelUtil.getAllFillLevels(object, fillLevelInfo) - + local fillLevelInfo = FillLevelUtil.getAllFillLevels(object) local totalFillLevel = 0 local totalCapacity = 0 for fillType, data in pairs(fillLevelInfo) do @@ -248,4 +248,50 @@ function FillLevelUtil.getTrailerFillLevels(trailer) end end return totalFillLevel, totalCapacity, totalFreeCapacity +end + + +--- Gets all currently possible fill types. +---@param vehicle table +---@param lambda function|nil +---@return table[] all fill types +---@return table fill types as keys +function FillLevelUtil.getAllValidFillTypes(vehicle, lambda, ...) + local fillTypes, fillTypesByIndex = {}, {} + for _, v in pairs(vehicle:getChildVehicles()) do + if v.getFillUnits then + for ix, _ in pairs(v:getFillUnits()) do + for fillType, state in pairs(v:getFillUnitSupportedFillTypes(ix)) do + if state then + if not fillTypesByIndex[fillType] and + (lambda == nil or lambda(fillType, ...)) then + fillTypesByIndex[fillType] = true + table.insert(fillTypes, fillType) + end + end + end + end + end + end + return fillTypes, fillTypesByIndex +end + +--- Gets all discharge nodes. +---@param vehicle table +---@param lambda function|nil +---@return table[] +---@return table +function FillLevelUtil.getAllDischargeNodes(vehicle, lambda, ...) + local dischargeNodes, dischargeNodeToObject = {}, {} + for _, v in pairs(vehicle:getChildVehicles()) do + if v.spec_dischargeable then + for _, node in pairs(v.spec_dischargeable.dischargeNodes) do + if lambda == nil or lambda(node, ...) then + dischargeNodeToObject[node] = v + table.insert(dischargeNodes, node) + end + end + end + end + return dischargeNodes, dischargeNodeToObject end \ No newline at end of file diff --git a/scripts/dev/ConsoleCommands.lua b/scripts/dev/ConsoleCommands.lua index f61451450..50c66734d 100644 --- a/scripts/dev/ConsoleCommands.lua +++ b/scripts/dev/ConsoleCommands.lua @@ -1,4 +1,5 @@ --- All the cp console commands are here. +---@class CpConsoleCommands CpConsoleCommands = CpObject() CpConsoleCommands.commands = { @@ -276,4 +277,5 @@ if g_consoleCommands then g_consoleCommands:delete() end +---@type CpConsoleCommands g_consoleCommands = CpConsoleCommands() \ No newline at end of file diff --git a/scripts/editor/CourseEditor.lua b/scripts/editor/CourseEditor.lua index 24b7877cd..a65e7dbbf 100644 --- a/scripts/editor/CourseEditor.lua +++ b/scripts/editor/CourseEditor.lua @@ -40,8 +40,9 @@ function CourseEditor:loadCategory(path) local category = {} local xmlFile = XMLFile.load("cpConstructionCategories", path, self.categorySchema) xmlFile:iterate("Category.Tab", function (_, tabKey) + local tabName = xmlFile:getValue(tabKey .. "#name") local tab = { - name = xmlFile:getValue(tabKey .. "#name"), + name = self.TRANSLATION_PREFIX .. tabName .. "_title", iconSliceId = xmlFile:getValue(tabKey .. "#iconSliceId"), brushes = {} } @@ -53,7 +54,7 @@ function CourseEditor:loadCategory(path) iconSliceId = xmlFile:getValue(brushKey .. "#iconSliceId"), isCourseOnly = xmlFile:getValue(brushKey .. "#isCourseOnly"), brushParameters = { - self.TRANSLATION_PREFIX .. tab.name .. "_" .. name + self.TRANSLATION_PREFIX .. tabName .. "_" .. name } } table.insert(tab.brushes, brush) diff --git a/scripts/editor/EditorCourseWrapper.lua b/scripts/editor/EditorCourseWrapper.lua index bc7be7ced..0596b2827 100644 --- a/scripts/editor/EditorCourseWrapper.lua +++ b/scripts/editor/EditorCourseWrapper.lua @@ -16,7 +16,7 @@ function EditorCourseWrapper:getCourse() return self.course end -function EditorCourseWrapper:getWaypoints() +function EditorCourseWrapper:getVisiblePoints() return self.course.waypoints end diff --git a/scripts/editor/EditorGraphWrapper.lua b/scripts/editor/EditorGraphWrapper.lua new file mode 100644 index 000000000..ebcfd635e --- /dev/null +++ b/scripts/editor/EditorGraphWrapper.lua @@ -0,0 +1,561 @@ +---@class EditorGraphWrapper +EditorGraphWrapper = CpObject() +EditorGraphWrapper.MIN_DISTANCE = 1 +function EditorGraphWrapper:init(graph) + ---@type Graph + self.graph = graph + + self.selectedNodeIds = {} + self.hoveredNodeId = nil + self.disabledBuffer = {} + + self.visiblePoints = {} + self.lastPosition = {0, 0, 0} + self.isDirty = false + + ---@type GraphSegment + self.temporarySegment = GraphSegment() + ---@type GraphSegment + self.mirrorTemporarySegment = GraphSegment() + self.isMirrorTemporaryActive = false +end + +function EditorGraphWrapper:draw(position) + self.graph:draw(self.hoveredNodeId, self.selectedNodeIds) + self.temporarySegment:draw(nil, nil, true, + self:getPointByIndex(self:getFirstSelectedNodeID())) + if self.isMirrorTemporaryActive then + self.mirrorTemporarySegment:draw(nil, nil, true) + end + if not position then + return + end + local x, _, z = unpack(position) + if x == nil or z == nil then + return + end +end + +--- TODO only shown points/segments in range? +function EditorGraphWrapper:getVisiblePoints() + return self.graph:getAllPoints() +end + +--- +---@param id string|nil +---@return GraphPoint|nil +---@return string|nil +function EditorGraphWrapper:getPointByIndex(id) + local point = self.graph:getPointByIndex(id) + if point == nil then + return nil, "err_point_not_found" + end + return point +end + +---@param id string|nil +---@return GraphSegment|nil +---@return string|nil +function EditorGraphWrapper:getSegmentByIndex(id) + local point, err = self:getPointByIndex(id) + if point == nil then + return nil, err + end + ---@type GraphSegment + local segment = point:getParentNode() + if segment == nil then + return nil, "err_segment_not_found" + end + return segment +end + +---@return number|nil +---@return number|nil +---@return number|nil +function EditorGraphWrapper:getPositionByIndex(id) + local point = self:getPointByIndex(id) + if point == nil then + return + end + local x, y, z = point:getPosition() + return x, y, z +end + +---@param x number +---@param y number +---@param z number +---@return string|nil +function EditorGraphWrapper:createSegmentWithPoint(x, y, z) + if x == nil or z == nil then + return + end + local segment = self.graph:createSegmentWithPoint(x, y, z) + return segment:getChildNodeByIndex(1):getRelativeID() +end + +---@param segment GraphSegment +function EditorGraphWrapper:addSegment(segment) + self.graph:addNewSegment(segment) +end + +---@param id string|nil +---@return boolean +---@return string|nil +function EditorGraphWrapper:removePointByIndex(id) + local point, err = self:getPointByIndex(id) + if point == nil then + return false, err + end + local segment, err = self:getSegmentByIndex(id) + if segment == nil then + return false, err + end + point:unlink(function(p, segment) + if not segment:hasChildNodes() then + segment:unlink() + end + end) + GraphRebuildSegmentEvent.sendEvent(segment) + return true +end + +---@param id string|nil +---@param noEventSend boolean|nil +---@return boolean +---@return string|nil +function EditorGraphWrapper:removeSegmentByPointIndex(id, noEventSend) + local segment, err = self:getSegmentByIndex(id) + if segment == nil then + return false, err + end + if not noEventSend then + GraphRemoveSegmentEvent.sendEvent(segment) + end + g_graph:removeSegment(segment) + return true +end + +---@param id string +---@param newPoint GraphPoint +---@return boolean +---@return string|nil +---@return string|nil +function EditorGraphWrapper:insertPointBehindIndex(id, newPoint) + local point, err = self:getPointByIndex(id) + if point == nil then + return false, err + end + local segment, err = self:getSegmentByIndex(id) + if segment == nil then + return false, err + end + if point:getDistance2DToPoint(newPoint) <= EditorGraphWrapper.MIN_DISTANCE then + return false, "err_min_distance_to_small" + end + local ix = segment:getChildNodeIndex(point) + local newIx = segment:insertChildNodeAtIndex(newPoint, ix + 1) + GraphRebuildSegmentEvent.sendEvent(segment) + return true, nil, newIx +end + +---@param id string +---@param newPoint GraphPoint +---@return boolean +---@return string|nil +---@return string|nil +function EditorGraphWrapper:insertPointAheadOfIndex(id, newPoint) + local point, err = self:getPointByIndex(id) + if point == nil then + return false, err + end + local segment, err = self:getSegmentByIndex(id) + if segment == nil then + return false, err + end + if point:getDistance2DToPoint(newPoint) <= EditorGraphWrapper.MIN_DISTANCE then + return false, "err_min_distance_to_small" + end + local ix = segment:getChildNodeIndex(point) + local newIx = segment:insertChildNodeAtIndex(newPoint, ix) + GraphRebuildSegmentEvent.sendEvent(segment) + return true, nil, newIx +end + +---@param id string|nil +---@param dx number +---@param dy number +---@param dz number +---@return boolean +---@return string|nil +function EditorGraphWrapper:movePointByIndex(id, dx, dy, dz) + local point, err = self:getPointByIndex(id) + if point == nil then + return false, err + end + point:moveTo(dx, dy, dz) + return true +end + +---@param id string|nil +---@return boolean +---@return string|nil +function EditorGraphWrapper:changeSegmentDirection(id) + local segment, err = self:getSegmentByIndex(id) + if segment == nil then + return false, err + end + segment:changeDirection() + return true +end + +---@param id string|nil +---@return boolean +---@return string|nil +function EditorGraphWrapper:isFirstSegmentPoint(id) + local point, err = self:getPointByIndex(id) + if point == nil then + return false, err + end + return point:isFirstNode() +end + +---@param id string|nil +---@return boolean +---@return string|nil +function EditorGraphWrapper:isLastSegmentPoint(id) + local point, err = self:getPointByIndex(id) + if point == nil then + return false, err + end + return point:isLastNode() +end + +---@param id string|nil +---@return boolean +---@return string|nil +function EditorGraphWrapper:isNotFirstOrLastSegmentPoint(id) + local point, err = self:getPointByIndex(id) + if point == nil then + return false, err + end + local isFirst = self:isFirstSegmentPoint(id) + if isFirst then + return false, "err_node_is_first" + end + local isLast = self:isLastSegmentPoint(id) + if isLast then + return false, "err_node_is_last" + end + return true +end + +---@param id string|nil +---@return boolean +---@return string|nil +function EditorGraphWrapper:isFirstOrLastSegmentPoint(id) + local point, err = self:getPointByIndex(id) + if point == nil then + return false, err + end + local isFirst = self:isFirstSegmentPoint(id) + if isFirst then + return true + end + local isLast = self:isLastSegmentPoint(id) + if isLast then + return true + end + return false, "err_node_not_first_or_last" +end + +---@param id string|nil +---@return boolean +---@return string|nil +function EditorGraphWrapper:isOnlyNodeLeftInSegment(id) + local segment, err = self:getSegmentByIndex(id) + if segment == nil then + return false, err + end + return segment:getNumChildNodes() <= 1 +end + +---@param idA string|nil +---@param idB string|nil +---@return boolean +---@return string|nil +function EditorGraphWrapper:isSegmentIDEqual(idA, idB) + local segmentA, errA = self:getSegmentByIndex(idA) + local segmentB, errB = self:getSegmentByIndex(idB) + if segmentA == nil or segmentB == nil then + return false, errA or errB + end + return segmentA:getID() == segmentB:getID() +end + +---@param idA string|nil +---@param idB string|nil +---@return boolean +---@return string|nil +function EditorGraphWrapper:mergeSegments(idA, idB) + local segmentA, errA = self:getSegmentByIndex(idA) + local segmentB, errB = self:getSegmentByIndex(idB) + if segmentA == nil then + return false, errA + end + if segmentB == nil then + return false, errB + end + if segmentA:getID() == segmentB:getID() then + return false, "err_same_segment" + end + local success, err = self:isFirstOrLastSegmentPoint(idA) + if not success then + return false, err + end + local success, err = self:isFirstOrLastSegmentPoint(idB) + if not success then + return false, err + end + if self:isFirstSegmentPoint(idA) then + if self:isFirstSegmentPoint(idB) then + segmentA:prepandByChildren(segmentB, false) + else + segmentA:prepandByChildren(segmentB, true) + end + else + if self:isFirstSegmentPoint(idB) then + segmentA:extendByChildren(segmentB, false) + else + segmentA:extendByChildren(segmentB, true) + end + end + g_graph:removeSegment(segmentB) + GraphRebuildSegmentEvent.sendEvent(segmentA) + return true +end + +---@param id string|nil +---@return boolean +---@return string|nil +function EditorGraphWrapper:splitSegment(id) + local node, err = self:getPointByIndex(id) + if node == nil then + return false, err + end + local segment, err = self:getSegmentByIndex(id) + if segment == nil then + return false, err + end + local success, err = self:isNotFirstOrLastSegmentPoint(id) + if not success then + return false, err + end + local ix = segment:getChildNodeIndex(node) + local postNodes = segment:getChildNodesBetweenIndex(ix + 1, segment:getNumChildNodes()) + + local newSegment = GraphSegment() + newSegment:extendByChildNodes(postNodes, false) + g_graph:addNewSegment(newSegment) + segment:removeChildNodesBetweenIndex(ix + 1, segment:getNumChildNodes()) + GraphRebuildSegmentEvent.sendEvent(segment) + return true +end + +-------------------------- +--- Selected nodes +-------------------------- + +---@param ix string|nil +function EditorGraphWrapper:setSelected(ix) + if ix ~=nil then + self.selectedNodeIds[ix] = true + end +end + +---@return table +function EditorGraphWrapper:getSelectedNodeIDs() + return self.selectedNodeIds +end + +---@return string|nil +function EditorGraphWrapper:getFirstSelectedNodeID() + return next(self.selectedNodeIds) +end + +---@param ix string|nil +---@return boolean|nil +function EditorGraphWrapper:isSelected(ix) + return self.selectedNodeIds[ix] +end + +function EditorGraphWrapper:resetSelected() + self.selectedNodeIds = {} +end + +---@return boolean|nil +function EditorGraphWrapper:hasSelectedNode() + return next(self.selectedNodeIds) ~= nil +end + +-------------------------- +--- Hovered node nodes +-------------------------- + +function EditorGraphWrapper:setHovered(ix) + self.hoveredNodeId = ix +end + +function EditorGraphWrapper:isHovered(ix) + return ix ~= nil and self.hoveredNodeId == ix +end + +function EditorGraphWrapper:resetHovered() + self.hoveredNodeId = nil +end + +---------------------------- +--- Temporary Waypoints +---------------------------- + +---@param x any +---@param y any +---@param z any +---@return GraphPoint|nil +function EditorGraphWrapper:addTemporaryPoint(x, y, z) + if x == nil or y == nil or z == nil then + return + end + local point = GraphPoint() + point:setPosition(x, y, z) + self.temporarySegment:appendChildNode(point) + return point +end + + +---@param x any +---@param y any +---@param z any +---@return GraphPoint|nil +function EditorGraphWrapper:addMirrorTemporaryPoint(x, y, z) + if x == nil or y == nil or z == nil then + return + end + local point = GraphPoint() + point:setPosition(x, y, z) + self.mirrorTemporarySegment:insertChildNodeAtIndex(point, 1) + return point +end + + +---@return boolean +function EditorGraphWrapper:hasTemporaryPoints() + return self.temporarySegment:hasChildNodes() +end + +---@return GraphPoint[] +function EditorGraphWrapper:getTemporaryPoints() + return self.temporarySegment:getAllChildNodes() +end + +---@return GraphSegment +function EditorGraphWrapper:getTemporarySegment() + return self.temporarySegment +end + +---@return GraphPoint|nil +function EditorGraphWrapper:getFirstTemporaryPoint() + return self.temporarySegment:getChildNodeByIndex(1) +end + +---@return GraphPoint[] +function EditorGraphWrapper:cloneTemporarySegment() + return self.temporarySegment:clone() +end + +function EditorGraphWrapper:clearTemporaryPoints() + self.temporarySegment:clearChildNodes() + self.mirrorTemporarySegment:clearChildNodes() +end + +function EditorGraphWrapper:resetTemporaryPoints() + self:clearTemporaryPoints() +end + +---@param active boolean +function EditorGraphWrapper:setMirrorSegmentActive(active) + self.isMirrorTemporaryActive = active +end + +---@return boolean +function EditorGraphWrapper:isMirrorSegmentActive() + return self.isMirrorTemporaryActive +end + +function EditorGraphWrapper:toggleMirrorSegmentActive() + self.isMirrorTemporaryActive = not self.isMirrorTemporaryActive +end + +---@return GraphSegment +function EditorGraphWrapper:getMirrorSegment() + return self.mirrorTemporarySegment +end + +---------------------------- +--- Destinations +---------------------------- + +---@param id string|nil +---@return boolean +---@return string|nil +function EditorGraphWrapper:hasTargetByIndex(id) + local point, err = self:getPointByIndex(id) + if not point then + return false, err + end + if not point:hasTarget() then + return false, "err_target_not_found" + end + return true +end + +---@param id string|nil +---@return boolean +---@return string|nil +function EditorGraphWrapper:createTargetForIndex(id, name) + local point, err = self:getPointByIndex(id) + if not point then + return false, err + end + if not point:createTarget(name) then + return false, "err_already_has_target" + end + return true +end + +---@param id string|nil +---@return boolean|nil +---@return string|nil +function EditorGraphWrapper:removeTargetForIndex(id) + local point, err = self:getPointByIndex(id) + if not point then + return false, err + end + if not point:removeTarget() then + return false, "err_target_not_found" + end + return true +end + +---@param id string|nil +---@return GraphTarget|nil +---@return string|nil +function EditorGraphWrapper:getTargetForIndex(id) + local point, err = self:getPointByIndex(id) + if not point then + return nil, err + end + if not point:hasTarget() then + return nil, "err_target_not_found" + end + return point:getTarget() +end \ No newline at end of file diff --git a/scripts/editor/GraphEditor.lua b/scripts/editor/GraphEditor.lua new file mode 100644 index 000000000..2a817b7ec --- /dev/null +++ b/scripts/editor/GraphEditor.lua @@ -0,0 +1,64 @@ + +---@class GraphEditor : CourseEditor +GraphEditor = CpObject(CourseEditor) +GraphEditor.TRANSLATION_PREFIX = "CP_editor_graph_" + +function GraphEditor:init() + CourseEditor.init(self) + ---@type EditorGraphWrapper + self.graphWrapper = EditorGraphWrapper(g_graph) + self.title = string.format("TODO: CP GraphEditor") + + g_consoleCommands:registerConsoleCommand("cpOpenGraphEditor", "Opens the CP Graph editor", "open", self) +end + +function GraphEditor:load() + self.brushCategory = self:loadCategory(Utils.getFilename("config/GraphEditorCategories.xml", g_Courseplay.BASE_DIRECTORY)) +end + +function GraphEditor:draw(x, y, z) + self.graphWrapper:draw({x, y, z}) +end + +function GraphEditor:open() + self.isActive = true + g_messageCenter:publish(MessageType.GUI_CP_INGAME_OPEN_CONSTRUCTION_MENU, self) +end + +function GraphEditor:getStartPosition() + return +end + +function GraphEditor:getCourseWrapper() + return self.graphWrapper +end + +function GraphEditor:onClickExit(callbackFunc) + self.isActive = false + callbackFunc() + -- YesNoDialog.show( + -- function (self, clickOk) + -- self:deactivate(clickOk) + -- callbackFunc() + -- end, + -- self, string.format(g_i18n:getText("CP_GraphEditor_save_changes"), self.file:getName())) +end + + +--- Updates the course display, when a waypoint change happened. +function GraphEditor:updateChanges(ix) + -- self.courseDisplay:updateChanges(ix) +end + +--- Updates the course display, when a single waypoint change happened. +function GraphEditor:updateChangeSingle(ix) + -- self.courseDisplay:updateWaypoint(ix) +end + +--- Updates the course display, between to waypoints. +function GraphEditor:updateChangesBetween(firstIx, lastIx) + -- self.courseDisplay:updateChangesBetween(firstIx, lastIx) +end + +---@type GraphEditor +g_graphEditor = GraphEditor() \ No newline at end of file diff --git a/scripts/editor/brushes/BaseBrush.lua b/scripts/editor/brushes/BaseBrush.lua index 405250645..4e8d71756 100644 --- a/scripts/editor/brushes/BaseBrush.lua +++ b/scripts/editor/brushes/BaseBrush.lua @@ -2,6 +2,7 @@ Basic brush, that manipulates waypoints. ]] ---@class CpBrush +---@field setInputTextDirty function CpBrush = CpObject(ConstructionBrush) CpBrush.TRANSLATION_PREFIX = "CP_editor_" CpBrush.radius = 2 @@ -12,7 +13,7 @@ CpBrush.secondaryAxisText = "secondary_axis_text" CpBrush.tertiaryButtonText = "tertiary_text" CpBrush.inputTitle = "input_title" CpBrush.yesNoTitle = "yesNo_title" -CpBrush.errMessage = "err" +CpBrush.defaultErrorMessage = "err" CpBrush.ERR_MESSAGE_DURATION = 15 * 1000 -- 15 sec function CpBrush:init(cursor, camera, editor) self.isActive = false @@ -34,6 +35,7 @@ function CpBrush:init(cursor, camera, editor) self.cursor:setShape(GuiTopDownCursor.SHAPES.CIRCLE) self.lastHoveredIx = nil self.errorMsgTimer = CpTemporaryObject(false) + self.errorMsgText = nil self.editor = editor self.courseWrapper = editor:getCourseWrapper() end @@ -49,7 +51,7 @@ function CpBrush:getHoveredWaypointIx() return end -- try to get a waypoint in mouse range - for ix, point in ipairs(self.courseWrapper:getWaypoints()) do + for ix, point in ipairs(self.courseWrapper:getVisiblePoints()) do if self:isAtPos(point, x, y, z) then return ix end @@ -68,6 +70,8 @@ function CpBrush:update(dt) end if self.errorMsgTimer:get() then self.cursor:setErrorMessage(self:getErrorMessage()) + else + self.errorMsgText = nil end end @@ -90,17 +94,28 @@ function CpBrush:getTranslation(translation, ...) end function CpBrush:getErrorMessage() - return self:getTranslation(self.errMessage) + if self.errorMsgText then + return g_i18n:getText(self.editor.TRANSLATION_PREFIX .. self.errorMsgText) + end + return self:getTranslation(self.defaultErrorMessage) end -function CpBrush:setError() +---@param errMsg string|nil +function CpBrush:setError(errMsg) self.errorMsgTimer:set(true, self.ERR_MESSAGE_DURATION) + self.errorMsgText = errMsg + self:debug("setting error: %s", tostring(errMsg)) end function CpBrush:resetError() self.errorMsgTimer:reset() + self.errorMsgText = nil end function CpBrush:getTitle() return self:getTranslation("title") +end + +function CpBrush:debug(...) + CpUtil.info(...) end \ No newline at end of file diff --git a/scripts/editor/brushes/graph/GraphBrush.lua b/scripts/editor/brushes/graph/GraphBrush.lua new file mode 100644 index 000000000..0bb9f6f88 --- /dev/null +++ b/scripts/editor/brushes/graph/GraphBrush.lua @@ -0,0 +1,53 @@ +--[[ + Brushes that can be used for waypoint selection/manipulation. +]] +---@class GraphBrush : CpBrush +GraphBrush = CpObject(CpBrush) +GraphBrush.radius = 1 +GraphBrush.sizeModifierMax = 10 +function GraphBrush:init(...) + CpBrush.init(self, ...) + self.sizeModifier = 1 + ---@type EditorGraphWrapper + self.graphWrapper = self.courseWrapper +end + +function GraphBrush:changeSizeModifier(modifier) + self.sizeModifier = modifier + self.cursor:setShapeSize(self.radius * modifier * (1+self.camera.zoomFactor)) +end + +---@param point GraphPoint +---@param x number +---@param y number +---@param z number +---@return boolean|nil +function GraphBrush:isAtPos(point, x, y, z) + local dx, dy, dz = point:getPosition() + if MathUtil.getPointPointDistance(dx, dz, x, z) < self.radius * self.sizeModifier * (1 + self.camera.zoomFactor) then + return math.abs(dy + 2 - y) < 3 + end +end + +---@param excludeLambda any +---@return string|nil +function GraphBrush:getHoveredNodeId(excludeLambda) + local x, y, z = self.cursor:getPosition() + -- try to get a waypoint in mouse range + for _, point in pairs(self.graphWrapper:getVisiblePoints()) do + if self:isAtPos(point, x, y, z) then + if excludeLambda == nil or not excludeLambda(point:getRelativeID()) then + return point:getRelativeID() + end + end + end +end + +function GraphBrush:update(dt) + self.graphWrapper:setHovered(self:getHoveredNodeId()) + --- Updates the cursor size depending on the zoom. + self.cursor:setShapeSize(self.radius * self.sizeModifier * (1 + self.camera.zoomFactor)) + if self.errorMsgTimer:get() then + self.cursor:setErrorMessage(self:getErrorMessage()) + end +end \ No newline at end of file diff --git a/scripts/editor/brushes/graph/misc/ConnectWaypoints.lua b/scripts/editor/brushes/graph/misc/ConnectWaypoints.lua new file mode 100644 index 000000000..b98a2ec02 --- /dev/null +++ b/scripts/editor/brushes/graph/misc/ConnectWaypoints.lua @@ -0,0 +1,138 @@ + +--- Connects two waypoints. +---@class BrushConnect : GraphBrush +BrushConnect = CpObject(GraphBrush) + +BrushConnect.TYPE_NORMAL = 1 +BrushConnect.TYPE_LOW_PRIO = 2 +BrushConnect.TYPE_CROSSING = 3 +BrushConnect.TYPE_CROSSING_LOW_PRIO = 4 +BrushConnect.TYPE_REVERSE_NORMAL = 5 +BrushConnect.TYPE_DISCONNECT = 6 +BrushConnect.TYPE_MIN = 1 +BrushConnect.TYPE_MAX = 6 + +BrushConnect.typeTexts = { + [BrushConnect.TYPE_NORMAL] = "type_normal", + [BrushConnect.TYPE_LOW_PRIO] = "type_sub_route", + [BrushConnect.TYPE_REVERSE_NORMAL] = "type_reverse_route", + [BrushConnect.TYPE_CROSSING] = "type_crossing_route", + [BrushConnect.TYPE_CROSSING_LOW_PRIO] = "type_sub_crossing_route", + [BrushConnect.TYPE_DISCONNECT] = "type_disconnect_route", +} + +function BrushConnect:init(...) + GraphBrush.init(self, ...) + self.supportsPrimaryButton = true + self.supportsPrimaryDragging = true + self.supportsSecondaryButton = true + self.supportsTertiaryButton = true + + self.changedWaypoints = {} + + self.mode = self.TYPE_NORMAL +end + +function BrushConnect:onButtonPrimary(isDown, isDrag, isUp) + if self.selectedNodeId == nil and isDown then + self.selectedNodeId = self:getHoveredNodeId() + self.graphWrapper:setSelected(self.selectedNodeId) + return + end + local nodeId = self:getHoveredNodeId() + if nodeId ~= nil then + if isDrag then + if nodeId ~= self.selectedNodeId and not self.changedWaypoints[nodeId] then + self:connectWaypoints(self.selectedNodeId, nodeId) + self.selectedNodeId = nodeId + self.changedWaypoints[nodeId] = true + end + end + end + if isUp then + self.selectedNodeId = nil + self.graphWrapper:resetSelected() + self.changedWaypoints = {} + end +end + +function BrushConnect:onButtonSecondary() + local d = self.sizeModifier + 1 + if d > self.sizeModifierMax then + self:changeSizeModifier(1) + elseif d <= 0 then + self:changeSizeModifier(self.sizeModifierMax) + else + self:changeSizeModifier(d) + end + self:setInputTextDirty() +end + +function BrushConnect:connectWaypoints(nodeId, targetNodeId, sendEvent) + self.graphWrapper:setConnectionAndSubPriority(nodeId, targetNodeId, self:getCurrentConnectionType(), self:getIsSubPrio(), sendEvent) +end + +function BrushConnect:getCurrentConnectionType() + local dir = 1 + if self.mode == self.TYPE_CROSSING or self.mode == self.TYPE_CROSSING_LOW_PRIO then + dir = 3 + elseif self.mode == self.TYPE_REVERSE_NORMAL then + dir = 4 + elseif self.mode == self.TYPE_DISCONNECT then + dir = 0 + end + return dir +end + +function BrushConnect:getIsSubPrio() + return self.mode == self.TYPE_LOW_PRIO or self.mode == self.TYPE_CROSSING_LOW_PRIO +end + +function BrushConnect:getIsReverse() + return self.mode == self.TYPE_REVERSE_NORMAL +end + +function BrushConnect:getIsCrossing() + return self.mode == self.TYPE_CROSSING or self.mode == self.TYPE_CROSSING_LOW_PRIO +end + +function BrushConnect:onButtonTertiary() + self.mode = self.mode + 1 + if self.mode > self.TYPE_MAX then + self.mode = self.TYPE_MIN + end + self:setInputTextDirty() +end + +--- Not working, as the brush classes need to be the same. +function BrushConnect:copyState(from) + if from.mode ~= nil then + self.mode = from.mode + self.sizeModifier = from.sizeModifier or 1 + self:setInputTextDirty() + end +end + +function BrushConnect:activate() + self.selectedNodeId = nil + self.changedWaypoints = {} + self.graphWrapper:resetSelected() +end + +function BrushConnect:deactivate() + self.selectedNodeId = nil + self.changedWaypoints = {} + self.graphWrapper:resetSelected() +end + +function BrushConnect:getButtonPrimaryText() + return self:getTranslation(self.primaryButtonText) +end + +function BrushConnect:getButtonSecondaryText() + return self:getTranslation(self.secondaryButtonText, self.sizeModifier) +end + +function BrushConnect:getButtonTertiaryText() + return self:getTranslation(self.tertiaryButtonText, self:getTranslation(self.typeTexts[self.mode])) +end diff --git a/scripts/editor/brushes/graph/misc/CurveLineWaypoints.lua b/scripts/editor/brushes/graph/misc/CurveLineWaypoints.lua new file mode 100644 index 000000000..12e9546ef --- /dev/null +++ b/scripts/editor/brushes/graph/misc/CurveLineWaypoints.lua @@ -0,0 +1,105 @@ + +--- Connects two waypoints. +---@class BrushCurve : BrushStraightLine +BrushCurve = CpObject(BrushStraightLine) +BrushCurve.MIN_OFFSET = -1 +BrushCurve.MAX_OFFSET = 1 +BrushCurve.MIN_CENTER = 0 +BrushCurve.MAX_CENTER = 1 +BrushCurve.START_CENTER = 0.5 +BrushCurve.START_OFFSET = 0 + +function BrushCurve:init(...) + BrushStraightLine.init(self, ...) + self.supportsSecondaryAxis = true + self.secondaryAxisIsContinuous = true + self.primaryAxisIsContinuous = true + self.offset = 0 + self.center = self.START_CENTER +end + +--- De-Casteljau algorithm +function BrushCurve:getNextPoint(t,points) + local q0_x, q0_y = (1-t) * points[1][1] + t * points[2][1], + (1-t) * points[1][2] + t * points[2][2] + local q1_x, q1_y = (1-t) * points[2][1] + t * points[3][1], + (1-t) * points[2][2] + t * points[3][2] + + return (1-t)*q0_x + t*q1_x, (1-t)*q0_y + t*q1_y +end + +function BrushCurve:moveWaypoints() + local x, y, z = self.cursor:getPosition() + if x == nil then + return + end + local waypoints = self.graphWrapper:cloneTemporaryPoints() + self.graphWrapper:clearTemporaryPoints() + local tx, ty, tz = 0, 0, 0 + if self.startAnchorWaypointId ~= nil then + tx, ty, tz = self.graphWrapper:getPosition(self.startAnchorWaypointId) + else + tx, ty, tz = waypoints[1].x, waypoints[1].y, waypoints[1].z + self.graphWrapper:addTemporaryPoint(tx, ty, tz) + end + local dist = MathUtil.vector2Length(x-tx,z-tz) + local spacing = 2 + local nx, nz = MathUtil.vector2Normalize(x-tx, z-tz) + if nx == nil or nz == nil then + nx = 0 + nz = 1 + end + local distCenter = dist*self.center + local ax, az = tx + nx * distCenter, tz + nz * distCenter + --- Rotation + local ncx = nx * math.cos(math.pi/2) - nz * math.sin(math.pi/2) + local ncz = nx * math.sin(math.pi/2) + nz * math.cos(math.pi/2) + --- Translation + local cx, cz = ax + ncx * self.offset * dist, az + ncz * self.offset * dist + local halfDist = MathUtil.vector2Length(cx - tx, cz - tz) + local dt = 2/(1.5*halfDist) + local n = math.ceil(halfDist/spacing) + spacing = halfDist/n + local points = { + { + tx, + tz + }, + { + cx, + cz + }, + { + x, + z + } + } + + local dx, dz + for t=dt , 1, dt do + dx, dz = BrushCurve:getNextPoint(t,points) + self.graphWrapper:addTemporaryPoint(dx, y, dz) + end +end + +function BrushCurve:onAxisPrimary(inputValue) + self.offset = math.clamp(self.offset+inputValue/50,self.MIN_OFFSET,self.MAX_OFFSET) + self:setInputTextDirty() +end + +function BrushCurve:onAxisSecondary(inputValue) + self.center = math.clamp(self.center+inputValue/50,self.MIN_CENTER,self.MAX_CENTER) + self:setInputTextDirty() +end + +function BrushCurve:getButtonPrimaryText() + return self:getTranslation(self.primaryButtonText) +end + +function BrushCurve:getAxisPrimaryText() + return self:getTranslation(self.primaryAxisText, self.offset) +end + +function BrushCurve:getAxisSecondaryText() + return self:getTranslation(self.secondaryAxisText, self.center) +end diff --git a/scripts/editor/brushes/graph/misc/MoveAdvancedWaypoint.lua b/scripts/editor/brushes/graph/misc/MoveAdvancedWaypoint.lua new file mode 100644 index 000000000..9ab929eda --- /dev/null +++ b/scripts/editor/brushes/graph/misc/MoveAdvancedWaypoint.lua @@ -0,0 +1,72 @@ + +--- Moves a waypoint relative to the mouse position. +---@class BrushMoveAdvanced : GraphBrush +BrushMoveAdvanced = CpObject(GraphBrush) + +function BrushMoveAdvanced:init(...) + GraphBrush.init(self, ...) + self.supportsPrimaryButton = true + self.supportsPrimaryDragging = true + self.supportsSecondaryButton = true + self.supportsSecondaryDragging = true + self.supportsTertiaryButton = true + self.selectedNodes = {} + self.lastPosition = {} +end + +function BrushMoveAdvanced:onButtonPrimary(isDown, isDrag, isUp) + local x, y, z = self.cursor:getPosition() + if isDown then + self.lastPosition = {x, y, z} + end + if isDrag then + if next(self.lastPosition) ~= nil then + local dx, dy, dz = unpack(self.lastPosition) + local nx, ny, nz = x - dx, y - dy, z - dz + for nodeId, _ in pairs(self.selectedNodes) do + self.graphWrapper:translateTo(nodeId, nx, ny, nz) + end + end + self.lastPosition = {x, y, z} + end +end + +function BrushMoveAdvanced:onButtonSecondary(isDown, isDrag, isUp) + if isDown or isDrag then + local selectedNodeId = self:getHoveredNodeId() + if selectedNodeId then + self.graphWrapper:setSelected(selectedNodeId) + self.selectedNodes[selectedNodeId] = true + end + end +end + +function BrushMoveAdvanced:onButtonTertiary() + self.selectedNodes = {} + self.lastPosition = {} + self.graphWrapper:resetSelected() +end + +function BrushMoveAdvanced:activate() + self.selectedNodes = {} + self.lastPosition = {} + self.graphWrapper:resetSelected() +end + +function BrushMoveAdvanced:deactivate() + self.selectedNodes = {} + self.lastPosition = {} + self.graphWrapper:resetSelected() +end + +function BrushMoveAdvanced:getButtonPrimaryText() + return self:getTranslation(self.primaryButtonText) +end + +function BrushMoveAdvanced:getButtonSecondaryText() + return self:getTranslation(self.secondaryButtonText) +end + +function BrushMoveAdvanced:getButtonTertiaryText() + return self:getTranslation(self.tertiaryButtonText) +end diff --git a/scripts/editor/brushes/graph/points/DeletePointBrush.lua b/scripts/editor/brushes/graph/points/DeletePointBrush.lua new file mode 100644 index 000000000..15ebb5b15 --- /dev/null +++ b/scripts/editor/brushes/graph/points/DeletePointBrush.lua @@ -0,0 +1,44 @@ + +--- Creates a new waypoint at the mouse position. +---@class DeletePointBrush : GraphBrush +DeletePointBrush = CpObject(GraphBrush) + +function DeletePointBrush:init(...) + GraphBrush.init(self, ...) + self.supportsPrimaryButton = true + self.supportsPrimaryDragging = true + -- self.supportsPrimaryAxis = true + return self +end + +function DeletePointBrush:onButtonPrimary(isDown, isDrag, isUp) + local nodeId = self:getHoveredNodeId() + if nodeId ~= nil then + if isDown or isDrag then + local succes, err = self.graphWrapper:removePointByIndex(nodeId) + if not succes then + self:setError(err) + end + end + end +end + +function DeletePointBrush:onAxisPrimary(delta) + local d = self.sizeModifier + delta + if d > self.sizeModifierMax then + self:changeSizeModifier(1) + elseif d <= 0 then + self:changeSizeModifier(self.sizeModifierMax) + else + self:changeSizeModifier(d) + end + self:setInputTextDirty() +end + +function DeletePointBrush:getButtonPrimaryText() + return self:getTranslation(self.primaryButtonText) +end + +function DeletePointBrush:getAxisPrimaryText() + return self:getTranslation(self.primaryAxisText, self.sizeModifier) +end \ No newline at end of file diff --git a/scripts/editor/brushes/graph/points/InsertPointBrush.lua b/scripts/editor/brushes/graph/points/InsertPointBrush.lua new file mode 100644 index 000000000..827e70c52 --- /dev/null +++ b/scripts/editor/brushes/graph/points/InsertPointBrush.lua @@ -0,0 +1,111 @@ + +--- Inserts a new waypoint at the mouse position. +---@class InsertPointBrush : GraphBrush +InsertPointBrush = CpObject(GraphBrush) +function InsertPointBrush:init(...) + GraphBrush.init(self, ...) + self.supportsPrimaryButton = true + self.supportsSecondaryButton = true +end + +function InsertPointBrush:onButtonPrimary() + self:handleButtonEvent(function (selectedId, point) + local success, err = self.graphWrapper:insertPointBehindIndex(selectedId, point:clone()) + if not success then + self:setError(err) + return + end + self:debug("Successfully inserted Point: %s behind index: %s", + point:getRelativeID(), selectedId) + end) +end + +function InsertPointBrush:onButtonSecondary() + -- self:handleButtonEvent(function (selectedId, point) + -- local success, err = self.graphWrapper:insertPointAheadOfIndex(selectedId, point:clone()) + -- if not success then + -- self:setError(err) + -- return + -- end + -- self:debug("Successfully inserted Point: %s ahead of index: %s", + -- point:getRelativeID(), selectedId) + -- end) + if self.graphWrapper:hasSelectedNode() then + local ix = self.graphWrapper:getFirstSelectedNodeID() + if self.graphWrapper:isOnlyNodeLeftInSegment(ix) then + self.graphWrapper:removeSegmentByPointIndex(ix) + end + end + self.graphWrapper:resetTemporaryPoints() + self.graphWrapper:resetSelected() +end + +function InsertPointBrush:handleButtonEvent(insertLambda) + local ix = self:getHoveredNodeId() + local x, y, z = self.cursor:getPosition() + if self.graphWrapper:hasSelectedNode() then + local point = self.graphWrapper:getFirstTemporaryPoint() + local selectedId = self.graphWrapper:getFirstSelectedNodeID() + local segment, err = self.graphWrapper:getSegmentByIndex(selectedId) + if not segment then + self:setError(err) + return + end + if ix then + self:setError("err_min_distance_to_small") + return + end + if selectedId and point then + insertLambda(selectedId, point) + end + self.graphWrapper:resetTemporaryPoints() + self.graphWrapper:resetSelected() + self.graphWrapper:setSelected(segment:getLastNodeID()) + else + if ix then + self.graphWrapper:setSelected(ix) + else + ix = self.graphWrapper:createSegmentWithPoint(x, y, z) + if ix then + self.graphWrapper:setSelected(ix) + end + end + end +end + +function InsertPointBrush:update(dt) + GraphBrush.update(self, dt) + local x, y, z = self.cursor:getPosition() + if x == nil or z == nil then + return + end + local tx, ty, tz = self.graphWrapper:getPositionByIndex( + self.graphWrapper:getFirstSelectedNodeID()) + if tx == nil or tz == nil then + return + end + self.graphWrapper:clearTemporaryPoints() + local dist = MathUtil.vector2Length(x-tx, z-tz) + if dist <= 1 then + return + end + self.graphWrapper:addTemporaryPoint(x, y, z) +end + +function InsertPointBrush:activate() + self.graphWrapper:resetSelected() + self.graphWrapper:resetTemporaryPoints() +end + +function InsertPointBrush:deactivate() + self:onButtonSecondary() +end + + +function InsertPointBrush:getButtonPrimaryText() + return self:getTranslation(self.primaryButtonText) +end + +function InsertPointBrush:getButtonSecondaryText() + return self:getTranslation(self.secondaryButtonText) +end diff --git a/scripts/editor/brushes/graph/points/LinePointBrush.lua b/scripts/editor/brushes/graph/points/LinePointBrush.lua new file mode 100644 index 000000000..bbbaa85c4 --- /dev/null +++ b/scripts/editor/brushes/graph/points/LinePointBrush.lua @@ -0,0 +1,204 @@ + +--- Inserts a new waypoint at the mouse position. +---@class LinePointBrush : GraphBrush +LinePointBrush = CpObject(GraphBrush) +LinePointBrush.MIN_OFFSET = -1 +LinePointBrush.MAX_OFFSET = 1 +LinePointBrush.MIN_CENTER = 1 +LinePointBrush.MAX_CENTER = 5 +function LinePointBrush:init(...) + GraphBrush.init(self, ...) + self.supportsPrimaryButton = true + self.supportsSecondaryButton = true + self.supportsTertiaryButton = true + self.supportsPrimaryAxis = true + self.primaryAxisIsContinuous = true + self.supportsSecondaryAxis = true + self.secondaryAxisIsContinuous = true + self.offset = 0 + self.center = -3.5 +end + +function LinePointBrush:onButtonPrimary() + local ix = self:getHoveredNodeId() + local x, y, z = self.cursor:getPosition() + if self.graphWrapper:hasSelectedNode() then + local tempSegment = self.graphWrapper:getTemporarySegment() + local selectedNodeId = self.graphWrapper:getFirstSelectedNodeID() + if tempSegment:getLength() < EditorGraphWrapper.MIN_DISTANCE then + self:setError("err_min_distance_to_small") + return + end + local segment, err = self.graphWrapper:getSegmentByIndex(selectedNodeId) + if not segment then + self:setError(err) + return + end + if ix then + local success, err = self.graphWrapper:isFirstSegmentPoint(ix) + if not success then + self:setError(err) + return + end + segment:extendByChildren(tempSegment, false) + local success, err = self.graphWrapper:mergeSegments( + segment:getLastNodeID(), ix) + if not success then + self:setError(err) + return + end + else + segment:extendByChildren(tempSegment, false) + end + if self.graphWrapper:isMirrorSegmentActive() then + local mirrorSegment = self.graphWrapper:getMirrorSegment() + self.graphWrapper:addSegment(mirrorSegment:clone(true)) + end + self.graphWrapper:clearTemporaryPoints() + self.graphWrapper:resetSelected() + self.graphWrapper:setSelected(segment:getLastNodeID()) + self.offset = 0 + else + if ix then + local isNotFirsOrLast, err = self.graphWrapper:isNotFirstOrLastSegmentPoint(ix) + if isNotFirsOrLast then + self:setError(err) + return + end + if self.graphWrapper:isFirstSegmentPoint(ix) then + self.graphWrapper:setSelected(ix) + self.graphWrapper:addTemporaryPoint(x, y, z) + elseif self.graphWrapper:isLastSegmentPoint(ix) then + self.graphWrapper:setSelected(ix) + self.graphWrapper:addTemporaryPoint(x, y, z) + end + else + ix = self.graphWrapper:createSegmentWithPoint(x, y, z) + if ix then + self.graphWrapper:setSelected(ix) + self.graphWrapper:addTemporaryPoint(x, y, z) + end + end + end +end + +function LinePointBrush:onButtonSecondary() + if self.graphWrapper:hasSelectedNode() then + local ix = self.graphWrapper:getFirstSelectedNodeID() + if self.graphWrapper:isOnlyNodeLeftInSegment(ix) then + self.graphWrapper:removeSegmentByPointIndex(ix) + end + end + self.offset = 0 + self.graphWrapper:resetTemporaryPoints() + self.graphWrapper:resetSelected() +end + +function LinePointBrush:onButtonTertiary() + self.graphWrapper:toggleMirrorSegmentActive() + self:setInputTextDirty() +end + +function LinePointBrush:update(dt) + GraphBrush.update(self, dt) + if self.graphWrapper:hasSelectedNode() then + self:movePoints() + end +end + +function LinePointBrush:movePoints() + local x, y, z = self.cursor:getPosition() + if x == nil or z == nil then + return + end + local tx, ty, tz = self.graphWrapper:getPositionByIndex( + self.graphWrapper:getFirstSelectedNodeID()) + if tx == nil or tz == nil then + return + end + self.graphWrapper:clearTemporaryPoints() + local dist = MathUtil.vector2Length(x-tx, z-tz) + if dist <= 1 then + return + end + local spacing = 3 + local nx, nz = MathUtil.vector2Normalize(x-tx, z-tz) + if nx == nil or nz == nil then + nx = 0 + nz = 1 + end + local distCenter = dist * 0.5 + local ax, az = tx + nx * distCenter, tz + nz * distCenter + --- Rotation + local ncx = nx * math.cos(math.pi/2) - nz * math.sin(math.pi/2) + local ncz = nx * math.sin(math.pi/2) + nz * math.cos(math.pi/2) + --- Translation + local cx, cz = ax + ncx * self.offset * dist, az + ncz * self.offset * dist + local halfDist = MathUtil.vector2Length(cx - tx, cz - tz) + local dt = 3/halfDist + local n = math.ceil(halfDist/spacing) + spacing = halfDist/n + local points = { + { tx, tz }, + { cx, cz }, + { x, z}} + local dx, dz + self.graphWrapper:addMirrorTemporaryPoint( + tx + ncx * self.center, ty, tz + ncz * self.center) + local lastY = ty + for t=dt , 1, dt do + dx, dz = CpMathUtil.de_casteljau(t, points) + local _, _, dy = RaycastUtil.raycastClosest(dx, lastY + 3, dz, 0, -1, 0, 5, + CollisionFlag.STATIC_OBJECT + CollisionFlag.ROAD + CollisionFlag.AI_DRIVABLE + CollisionFlag.TERRAIN) + lastY = dy + self.graphWrapper:addTemporaryPoint(dx, y, dz) + local mx, mz = dx + ncx * self.center, dz + ncz * self.center + self.graphWrapper:addMirrorTemporaryPoint(mx, y, mz) + end +end + +function LinePointBrush:onAxisPrimary(inputValue) + self.offset = math.clamp(self.offset+inputValue/125, self.MIN_OFFSET, self.MAX_OFFSET) + self:setInputTextDirty() +end + +function LinePointBrush:onAxisSecondary(inputValue) + local newCenter = self.center - inputValue/20 + if self.center < 0 and newCenter > -1 then + self.center = 1 + elseif self.center > 0 and newCenter < 1 then + self.center = -1 + else + self.center = math.clamp(newCenter, -self.MAX_CENTER, self.MAX_CENTER) + end + self:setInputTextDirty() +end + +function LinePointBrush:activate() + self.graphWrapper:resetSelected() + self.graphWrapper:resetTemporaryPoints() +end + +function LinePointBrush:deactivate() + self:onButtonSecondary() + self.graphWrapper:setMirrorSegmentActive(false) +end +function LinePointBrush:getButtonPrimaryText() + return self:getTranslation(self.primaryButtonText) +end + +function LinePointBrush:getButtonSecondaryText() + return self:getTranslation(self.secondaryButtonText) +end + +function LinePointBrush:getButtonTertiaryText() + return self:getTranslation(self.tertiaryButtonText) +end + +function LinePointBrush:getAxisPrimaryText() + return self:getTranslation(self.primaryAxisText, self.offset) +end + +function LinePointBrush:getAxisSecondaryText() + return self:getTranslation(self.secondaryAxisText, self.center) +end diff --git a/scripts/editor/brushes/graph/points/MovePointBrush.lua b/scripts/editor/brushes/graph/points/MovePointBrush.lua new file mode 100644 index 000000000..611b919df --- /dev/null +++ b/scripts/editor/brushes/graph/points/MovePointBrush.lua @@ -0,0 +1,48 @@ + +--- Moves a waypoint relative to the mouse position. +---@class MovePointBrush : GraphBrush +MovePointBrush = CpObject(GraphBrush) + +function MovePointBrush:init(...) + GraphBrush.init(self, ...) + self.supportsPrimaryButton = true + self.supportsPrimaryDragging = true + + return self +end + +function MovePointBrush:onButtonPrimary(isDown, isDrag, isUp) + if isDown then + self.graphWrapper:setSelected(self:getHoveredNodeId()) + end + if isDrag and self.graphWrapper:hasSelectedNode() then + local id = self.graphWrapper:getFirstSelectedNodeID() + local x, y, z = self.cursor:getPosition() + self.graphWrapper:movePointByIndex(id, x, y, z) + end + if isUp then + local id = self.graphWrapper:getFirstSelectedNodeID() + local segment = self.graphWrapper:getSegmentByIndex(id) + if segment then + GraphRebuildSegmentEvent.sendEvent(segment) + end + self.graphWrapper:resetSelected() + end +end + +function MovePointBrush:activate() + self.graphWrapper:resetSelected() +end + +function MovePointBrush:deactivate() + local id = self.graphWrapper:getFirstSelectedNodeID() + local segment = self.graphWrapper:getSegmentByIndex(id) + if segment then + GraphRebuildSegmentEvent.sendEvent(segment) + end + self.graphWrapper:resetSelected() +end + +function MovePointBrush:getButtonPrimaryText() + return self:getTranslation(self.primaryButtonText) +end \ No newline at end of file diff --git a/scripts/editor/brushes/graph/segments/ChangeSegmentTypBrush.lua b/scripts/editor/brushes/graph/segments/ChangeSegmentTypBrush.lua new file mode 100644 index 000000000..12c64b64e --- /dev/null +++ b/scripts/editor/brushes/graph/segments/ChangeSegmentTypBrush.lua @@ -0,0 +1,21 @@ + +--- Creates a new segement with a point at the mouse position. +---@class ChangeSegmentTypBrush : GraphBrush +ChangeSegmentTypBrush = CpObject(GraphBrush) + +function ChangeSegmentTypBrush:init(...) + GraphBrush.init(self, ...) + self.supportsPrimaryButton = true +end + +function ChangeSegmentTypBrush:onButtonPrimary() + local nodeId = self:getHoveredNodeId() + local success, err = self.graphWrapper:changeSegmentDirection(nodeId) + if not success then + self:setError(err) + end +end + +function ChangeSegmentTypBrush:getButtonPrimaryText() + return self:getTranslation(self.primaryButtonText) +end diff --git a/scripts/editor/brushes/graph/segments/CreateSegmentBrush.lua b/scripts/editor/brushes/graph/segments/CreateSegmentBrush.lua new file mode 100644 index 000000000..a51b94542 --- /dev/null +++ b/scripts/editor/brushes/graph/segments/CreateSegmentBrush.lua @@ -0,0 +1,24 @@ + +--- Creates a new segement with a point at the mouse position. +---@class CreateSegmentBrush : GraphBrush +CreateSegmentBrush = CpObject(GraphBrush) + +function CreateSegmentBrush:init(...) + GraphBrush.init(self, ...) + self.supportsPrimaryButton = true +end + +function CreateSegmentBrush:onButtonPrimary() + if self:getHoveredNodeId() then + self:setError() + return + end + local x, y, z = self.cursor:getPosition() + if not self.graphWrapper:createSegmentWithPoint(x, y, z) then + self:setError() + end +end + +function CreateSegmentBrush:getButtonPrimaryText() + return self:getTranslation(self.primaryButtonText) +end diff --git a/scripts/editor/brushes/graph/segments/DeleteSegmentBrush.lua b/scripts/editor/brushes/graph/segments/DeleteSegmentBrush.lua new file mode 100644 index 000000000..c2ee8a478 --- /dev/null +++ b/scripts/editor/brushes/graph/segments/DeleteSegmentBrush.lua @@ -0,0 +1,43 @@ + +--- Creates a new waypoint at the mouse position. +---@class DeleteSegmentBrush : GraphBrush +DeleteSegmentBrush = CpObject(GraphBrush) + +function DeleteSegmentBrush:init(...) + GraphBrush.init(self, ...) + self.supportsPrimaryButton = true + self.supportsPrimaryDragging = true + -- self.supportsPrimaryAxis = true + return self +end + +function DeleteSegmentBrush:onButtonPrimary(isDown, isDrag, isUp) + local nodeId = self:getHoveredNodeId() + if nodeId ~= nil then + if isDown or isDrag then + if not self.graphWrapper:removeSegmentByPointIndex(nodeId) then + self:setError() + end + end + end +end + +function DeleteSegmentBrush:onAxisPrimary(delta) + local d = self.sizeModifier + delta + if d > self.sizeModifierMax then + self:changeSizeModifier(1) + elseif d <= 0 then + self:changeSizeModifier(self.sizeModifierMax) + else + self:changeSizeModifier(d) + end + self:setInputTextDirty() +end + +function DeleteSegmentBrush:getButtonPrimaryText() + return self:getTranslation(self.primaryButtonText) +end + +function DeleteSegmentBrush:getAxisPrimaryText() + return self:getTranslation(self.primaryAxisText, self.sizeModifier) +end \ No newline at end of file diff --git a/scripts/editor/brushes/graph/segments/MergeSplitSegmentBrush.lua b/scripts/editor/brushes/graph/segments/MergeSplitSegmentBrush.lua new file mode 100644 index 000000000..79ba270f0 --- /dev/null +++ b/scripts/editor/brushes/graph/segments/MergeSplitSegmentBrush.lua @@ -0,0 +1,52 @@ +--- Creates a new waypoint at the mouse position. +---@class MergeSplitSegmentBrush : GraphBrush +MergeSplitSegmentBrush = CpObject(GraphBrush) + +function MergeSplitSegmentBrush:init(...) + GraphBrush.init(self, ...) + self.supportsPrimaryButton = true + self.supportsPrimaryDragging = true + self.supportsSecondaryButton = true + return self +end + +function MergeSplitSegmentBrush:onButtonPrimary(isDown, isDrag, isUp) + local nodeId = self:getHoveredNodeId() + if isDown and not self.graphWrapper:hasSelectedNode() then + if nodeId ~= nil then + local isFirstOrLast, err = self.graphWrapper:isFirstOrLastSegmentPoint(nodeId) + if not isFirstOrLast then + self:setError(err) + return + end + self.graphWrapper:setSelected(nodeId) + end + end + if isUp and self.graphWrapper:hasSelectedNode() then + local selectedId = self.graphWrapper:getFirstSelectedNodeID() + local success, err = self.graphWrapper:mergeSegments(nodeId, selectedId) + if not success then + self:setError(err) + end + self.graphWrapper:resetSelected() + end +end + +function MergeSplitSegmentBrush:onButtonSecondary() + local nodeId = self:getHoveredNodeId() + if nodeId ~= nil then + local success, err = self.graphWrapper:splitSegment(nodeId) + if not success then + self:setError(err) + return + end + end +end + +function MergeSplitSegmentBrush:getButtonPrimaryText() + return self:getTranslation(self.primaryButtonText) +end + +function MergeSplitSegmentBrush:getButtonSecondaryText() + return self:getTranslation(self.secondaryButtonText) +end \ No newline at end of file diff --git a/scripts/editor/brushes/graph/targets/CreateTargetBrush.lua b/scripts/editor/brushes/graph/targets/CreateTargetBrush.lua new file mode 100644 index 000000000..ec89dfbf8 --- /dev/null +++ b/scripts/editor/brushes/graph/targets/CreateTargetBrush.lua @@ -0,0 +1,28 @@ + +--- Creates a new waypoint at the mouse position. +---@class CreateTargetBrush : GraphBrush +CreateTargetBrush = CpObject(GraphBrush) +function CreateTargetBrush:init(...) + GraphBrush.init(self, ...) + self.supportsPrimaryButton = true + --self.supportsPrimaryDragging = true +end + +function CreateTargetBrush:onButtonPrimary() + local nodeId = self:getHoveredNodeId() + if nodeId ~= nil then + local found = self.graphWrapper:hasTargetByIndex(nodeId) + if found then + self:setError("err_already_has_target") + return + end + self:openTextInput(function(self, text, clickOk, nodeId) + if clickOk then + self.graphWrapper:createTargetForIndex(nodeId, text) + end + end, self:getTranslation(self.inputTitle), nodeId) + end +end +function CreateTargetBrush:getButtonPrimaryText() + return self:getTranslation(self.primaryButtonText) +end diff --git a/scripts/editor/brushes/graph/targets/DeleteTargetBrush.lua b/scripts/editor/brushes/graph/targets/DeleteTargetBrush.lua new file mode 100644 index 000000000..f4a46d95a --- /dev/null +++ b/scripts/editor/brushes/graph/targets/DeleteTargetBrush.lua @@ -0,0 +1,29 @@ + + +--- Creates a new waypoint at the mouse position. +---@class DeleteTargetBrush : GraphBrush +DeleteTargetBrush = CpObject(GraphBrush) + +function DeleteTargetBrush:init(...) + GraphBrush.init(self, ...) + self.supportsPrimaryButton = true + --self.supportsPrimaryDragging = true +end + +function DeleteTargetBrush:onButtonPrimary() + local nodeId = self:getHoveredNodeId() + if nodeId ~= nil then + local target, err = self.graphWrapper:getTargetForIndex(nodeId) + if not target then + self:setError(err) + return + end + self:showYesNoDialog(function() + self.graphWrapper:removeTargetForIndex(nodeId) + end, self:getTranslation(self.yesNoTitle, target:getName()), nodeId) + end +end + +function DeleteTargetBrush:getButtonPrimaryText() + return self:getTranslation(self.primaryButtonText) +end \ No newline at end of file diff --git a/scripts/editor/brushes/graph/targets/RenameTargetBrush.lua b/scripts/editor/brushes/graph/targets/RenameTargetBrush.lua new file mode 100644 index 000000000..d31df92f4 --- /dev/null +++ b/scripts/editor/brushes/graph/targets/RenameTargetBrush.lua @@ -0,0 +1,30 @@ + +--- Creates a new waypoint at the mouse position. +---@class RenameTargetBrush : GraphBrush +RenameTargetBrush = CpObject(GraphBrush) + +function RenameTargetBrush:init(...) + GraphBrush.init(self, ...) + self.supportsPrimaryButton = true + --self.supportsPrimaryDragging = true +end + +function RenameTargetBrush:onButtonPrimary() + local nodeId = self:getHoveredNodeId() + if nodeId ~= nil then + local target, err = self.graphWrapper:getTargetForIndex(nodeId) + if not target then + self:setError(err) + return + end + self:openTextInput(function(self, text, clickOk, target) + if clickOk then + target:rename(text) + end + end, self:getTranslation(self.inputTitle, target:getName()), target) + end +end + +function RenameTargetBrush:getButtonPrimaryText() + return self:getTranslation(self.primaryButtonText) +end \ No newline at end of file diff --git a/scripts/events/CpJoinEvent.lua b/scripts/events/CpJoinEvent.lua index 3cdf71a4e..8c1ee5eae 100644 --- a/scripts/events/CpJoinEvent.lua +++ b/scripts/events/CpJoinEvent.lua @@ -28,7 +28,8 @@ function CpJoinEvent:readStream(streamId, connection) for i = 1, #CpDebug.channels do CpDebug:setChannelActive(i, streamReadBool(streamId)) end - + g_graph:readStream(streamId, connection) + self:run(connection); end @@ -43,7 +44,7 @@ function CpJoinEvent:writeStream(streamId, connection) for i = 1, #CpDebug.channels do streamWriteBool(streamId, CpDebug:isChannelActive(i)) end - + g_graph:writeStream(streamId, connection) end --- Runs the event on the receiving end of the event. diff --git a/scripts/events/graph/GraphSegmentEvents.lua b/scripts/events/graph/GraphSegmentEvents.lua new file mode 100644 index 000000000..de306a405 --- /dev/null +++ b/scripts/events/graph/GraphSegmentEvents.lua @@ -0,0 +1,156 @@ +---@class GraphSegmentChangedAttributesEvent +---@field segment GraphSegment +GraphSegmentChangedAttributesEvent = {} +local GraphSegmentChangedAttributesEvent_mt = Class(GraphSegmentChangedAttributesEvent, Event) + +InitEventClass(GraphSegmentChangedAttributesEvent, 'GraphSegmentChangedAttributesEvent') + +function GraphSegmentChangedAttributesEvent.emptyNew() + return Event.new(GraphSegmentChangedAttributesEvent_mt) +end + +function GraphSegmentChangedAttributesEvent.new(segment) + local self = GraphSegmentChangedAttributesEvent.emptyNew() + self.segment = segment + return self +end + +function GraphSegmentChangedAttributesEvent:readStream(streamId, connection) + ---@type GraphSegment + local segment = g_graph:getChildNodeByIndex(streamReadUInt32(streamId)) + if segment then + segment:readStreamAttributes(streamId, connection) + end +end + +function GraphSegmentChangedAttributesEvent:writeStream(streamId, connection) + streamWriteUInt32(streamId, self.segment:getID()) + self.segment:writeStreamAttributes(streamId, connection) +end + +function GraphSegmentChangedAttributesEvent.sendEvent(segment) + if g_server ~= nil then + g_server:broadcastEvent(GraphSegmentChangedAttributesEvent.new(segment), + nil, nil, nil) + else + g_client:getServerConnection():sendEvent( + GraphSegmentChangedAttributesEvent.new(segment)) + end +end + +---@class GraphCreateSegmentEvent +---@field segment GraphSegment +GraphCreateSegmentEvent = {} +local GraphCreateSegmentEvent_mt = Class(GraphCreateSegmentEvent, Event) + +InitEventClass(GraphCreateSegmentEvent, 'GraphCreateSegmentEvent') + +function GraphCreateSegmentEvent.emptyNew() + return Event.new(GraphCreateSegmentEvent_mt) +end + +function GraphCreateSegmentEvent.new(segment) + local self = GraphCreateSegmentEvent.emptyNew() + self.segment = segment + return self +end + +function GraphCreateSegmentEvent:readStream(streamId, connection) + ---@type GraphSegment + local segment = GraphSegment() + segment:readStream(streamId, connection) + g_graph:addNewSegment(segment, true) +end + +function GraphCreateSegmentEvent:writeStream(streamId, connection) + self.segment:writeStream(streamId, connection) +end + +function GraphCreateSegmentEvent.sendEvent(segment) + if g_server ~= nil then + g_server:broadcastEvent(GraphCreateSegmentEvent.new(segment), + nil, nil, nil) + else + g_client:getServerConnection():sendEvent( + GraphCreateSegmentEvent.new(segment)) + end +end + +---@class GraphRemoveSegmentEvent +---@field segment GraphSegment +GraphRemoveSegmentEvent = {} +local GraphRemoveSegmentEvent_mt = Class(GraphRemoveSegmentEvent, Event) + +InitEventClass(GraphRemoveSegmentEvent, 'GraphRemoveSegmentEvent') + +function GraphRemoveSegmentEvent.emptyNew() + return Event.new(GraphRemoveSegmentEvent_mt) +end + +function GraphRemoveSegmentEvent.new(segment) + local self = GraphRemoveSegmentEvent.emptyNew() + self.segment = segment + return self +end + +function GraphRemoveSegmentEvent:readStream(streamId, connection) + ---@type GraphSegment + local segment = g_graph:getChildNodeByIndex(streamReadUInt32(streamId)) + if segment then + g_graph:removeSegment(segment, true) + end +end + +function GraphRemoveSegmentEvent:writeStream(streamId, connection) + streamWriteUInt32(streamId, self.segment:getID()) +end + +function GraphRemoveSegmentEvent.sendEvent(segment) + if g_server ~= nil then + g_server:broadcastEvent(GraphRemoveSegmentEvent.new(segment), + nil, nil, nil) + else + g_client:getServerConnection():sendEvent( + GraphRemoveSegmentEvent.new(segment)) + end +end + +---@class GraphRebuildSegmentEvent +---@field segment GraphSegment +GraphRebuildSegmentEvent = {} +local GraphRebuildSegmentEvent_mt = Class(GraphRebuildSegmentEvent, Event) + +InitEventClass(GraphRebuildSegmentEvent, 'GraphRebuildSegmentEvent') + +function GraphRebuildSegmentEvent.emptyNew() + return Event.new(GraphRebuildSegmentEvent_mt) +end + +function GraphRebuildSegmentEvent.new(segment) + local self = GraphRebuildSegmentEvent.emptyNew() + self.segment = segment + return self +end + +function GraphRebuildSegmentEvent:readStream(streamId, connection) + ---@type GraphSegment + local segment = g_graph:getChildNodeByIndex(streamReadUInt32(streamId)) + if segment then + segment:clearChildNodes() + segment:readStream(streamId, connection) + end +end + +function GraphRebuildSegmentEvent:writeStream(streamId, connection) + self.segment:writeStream(streamId, connection) +end + +function GraphRebuildSegmentEvent.sendEvent(segment) + if g_server ~= nil then + g_server:broadcastEvent(GraphRebuildSegmentEvent.new(segment), + nil, nil, nil) + else + g_client:getServerConnection():sendEvent( + GraphRebuildSegmentEvent.new(segment)) + end +end \ No newline at end of file diff --git a/scripts/events/graph/GraphTargetEvents.lua b/scripts/events/graph/GraphTargetEvents.lua new file mode 100644 index 000000000..a14fdc2c9 --- /dev/null +++ b/scripts/events/graph/GraphTargetEvents.lua @@ -0,0 +1,122 @@ +---@class GraphCreateTargetEvent +---@field point GraphPoint +---@field name string +GraphCreateTargetEvent = {} +local GraphCreateTargetEvent_mt = Class(GraphCreateTargetEvent, Event) + +InitEventClass(GraphCreateTargetEvent, 'GraphCreateTargetEvent') + +function GraphCreateTargetEvent.emptyNew() + return Event.new(GraphCreateTargetEvent_mt) +end + +function GraphCreateTargetEvent.new(point, name) + local self = GraphCreateTargetEvent.emptyNew() + self.point = point + self.name = name + return self +end + +function GraphCreateTargetEvent:readStream(streamId, connection) + local point = g_graph:getPointByIndex(streamReadString(streamId)) + local name = streamReadString(streamId) + if point then + point:createTarget(name, true) + end +end + +function GraphCreateTargetEvent:writeStream(streamId, connection) + streamWriteString(streamId, self.point:getRelativeID()) + streamWriteString(streamId, self.name) +end + +function GraphCreateTargetEvent.sendEvent(point, name) + if g_server ~= nil then + g_server:broadcastEvent(GraphCreateTargetEvent.new(point, name), + nil, nil, nil) + else + g_client:getServerConnection():sendEvent( + GraphCreateTargetEvent.new(point, name)) + end +end + +---@class GraphRenameTargetEvent +---@field point GraphPoint +---@field name string +GraphRenameTargetEvent = {} +local GraphRenameTargetEvent_mt = Class(GraphRenameTargetEvent, Event) + +InitEventClass(GraphRenameTargetEvent, 'GraphRenameTargetEvent') + +function GraphRenameTargetEvent.emptyNew() + return Event.new(GraphRenameTargetEvent_mt) +end + +function GraphRenameTargetEvent.new(point, name) + local self = GraphRenameTargetEvent.emptyNew() + self.point = point + self.name = name + return self +end + +function GraphRenameTargetEvent:readStream(streamId, connection) + local point = g_graph:getPointByIndex(streamReadString(streamId)) + local name = streamReadString(streamId) + if point then + local target = point:getTarget() + target:rename(name, true) + end +end + +function GraphRenameTargetEvent:writeStream(streamId, connection) + streamWriteString(streamId, self.point:getRelativeID()) + streamWriteString(streamId, self.name) +end + +function GraphRenameTargetEvent.sendEvent(point, name) + if g_server ~= nil then + g_server:broadcastEvent(GraphRenameTargetEvent.new(point, name), + nil, nil, nil) + else + g_client:getServerConnection():sendEvent( + GraphRenameTargetEvent.new(point, name)) + end +end + +---@class GraphRemoveTargetEvent +GraphRemoveTargetEvent = {} +local GraphRemoveTargetEvent_mt = Class(GraphRemoveTargetEvent, Event) + +InitEventClass(GraphRemoveTargetEvent, 'GraphRemoveTargetEvent') + +function GraphRemoveTargetEvent.emptyNew() + return Event.new(GraphRemoveTargetEvent_mt) +end + +function GraphRemoveTargetEvent.new(point, name) + local self = GraphRemoveTargetEvent.emptyNew() + self.point = point + self.name = name + return self +end + +function GraphRemoveTargetEvent:readStream(streamId, connection) + local point = g_graph:getPointByIndex(streamReadString(streamId)) + if point then + point:removeTarget(true) + end +end + +function GraphRemoveTargetEvent:writeStream(streamId, connection) + streamWriteString(streamId, self.point:getRelativeID()) +end + +function GraphRemoveTargetEvent.sendEvent(point) + if g_server ~= nil then + g_server:broadcastEvent(GraphRemoveTargetEvent.new(point), + nil, nil, nil) + else + g_client:getServerConnection():sendEvent( + GraphRemoveTargetEvent.new(point)) + end +end \ No newline at end of file diff --git a/scripts/geometry/Polyline.lua b/scripts/geometry/Polyline.lua index 0292892d5..e04bf6f10 100644 --- a/scripts/geometry/Polyline.lua +++ b/scripts/geometry/Polyline.lua @@ -46,8 +46,12 @@ function Polyline:prepend(v) end end +function Polyline:_getNewInstance() + return Polyline() +end + function Polyline:clone() - local clone = Polyline({}) + local clone = self:_getNewInstance() for _, v in ipairs(self) do clone:append(v:clone()) end @@ -460,7 +464,9 @@ end --- as a turn waypoint. ---@param r number turning radius ---@param makeCorners boolean if true, make corners for turn maneuvers instead of rounding them. -function Polyline:ensureMinimumRadius(r, makeCorners) +---@param maxCrossTrackError number|nil maximum cross track error allowed before we start adjusting corners, +---defaults to CourseGenerator.cMaxCrossTrackError +function Polyline:ensureMinimumRadius(r, makeCorners, maxCrossTrackError) ---@param entry CourseGenerator.Slider ---@param exit CourseGenerator.Slider @@ -488,8 +494,9 @@ function Polyline:ensureMinimumRadius(r, makeCorners) nextIx = currentIx + 1 local xte = self:at(currentIx):getXte(r) local radius = self:at(currentIx):getRadius() - if xte > CourseGenerator.cMaxCrossTrackError then - self.logger:debug('ensureMinimumRadius (%s): found a corner at %d with r: %.1f, r: %.1f, xte: %.1f', debugId, currentIx, radius, r, xte) + if xte > (maxCrossTrackError or CourseGenerator.cMaxCrossTrackError) then + self.logger:debug('ensureMinimumRadius (%s): found a corner at %d with r: %.1f, r: %.1f, xte: %.1f', + debugId, currentIx, radius, r, xte) -- looks like we can't make this turn without deviating too much from the course, local entry = CourseGenerator.Slider(self, currentIx, 0) local exit = CourseGenerator.Slider(self, currentIx, 0) @@ -864,7 +871,7 @@ end function Polyline:__tostring() local result = '' for i, v in ipairs(self) do - result = result .. string.format('%d %s\n', i, v) + result = result .. string.format('%d %s %s\n', i, v, v.xte) end return result end \ No newline at end of file diff --git a/scripts/graph/Graph.lua b/scripts/graph/Graph.lua new file mode 100644 index 000000000..48df35495 --- /dev/null +++ b/scripts/graph/Graph.lua @@ -0,0 +1,391 @@ +---@class Graph : GraphNode +---@field _childNodes GraphSegment[] +Graph = CpObject(GraphNode) +Graph.XML_KEY = "Graph" +function Graph:init() + GraphNode.init(self) + g_consoleCommands:registerConsoleCommand("cpGraphFindPathTo", + "Tries to find a path to: ", "consoleCommandFindPathTo", self) + g_consoleCommands:registerConsoleCommand("cpGraphGenerateFromSplines", + "Generates segments from traffic splines", + "consoleCommandGenerateSegmentsFromSplines", self) + + ---@type GraphTarget[] + self._targets = {} + self._hasGeneratedSplines = false +end + +function Graph:delete() + -- TODO: fix this and ConsoleCommands so we can unregister a single command. Otherwise reloading this + -- script won't work + removeConsoleCommand("cpGraphFindPathTo") + removeConsoleCommand("cpGraphGenerateFromSplines") +end + +function Graph:consoleCommandFindPathTo(name) + if not name then + return "No target given!" + end + local cmd = function () + local edges = {} + local targetPos + for _, seg in ipairs(self._childNodes) do + for _, node in ipairs(seg:getAllChildNodes()) do + local target = node:getTarget() + if target and target:getName() == name then + local x, z = node:getPosition2D() + targetPos = Vector(x, -z) + end + end + local edge = seg:toGraphEdge() + table.insert(edges, edge) + end + if targetPos == nil or targetPos.x == nil or targetPos.y == nil then + return "Failed to find target!" + end + local vehicle = CpUtil.getCurrentVehicle() + if vehicle == nil then + return "Must be in a vehicle!" + end + local pathfinder = GraphPathfinder(1000, 800, 25, edges) + local start = PathfinderUtil.getVehiclePositionAsState3D(vehicle) + local goal = State3D(targetPos.x, targetPos.y, 0, 0) + CpUtil.info("Goal: %s", tostring(goal)) + local TestConstraints = CpObject(PathfinderConstraintInterface) + local result = pathfinder:start(start, goal, AIUtil.getTurningRadius(vehicle), false, TestConstraints(), 0) + while not result.done do + result = pathfinder:resume() + end + if result.path == nil or #result.path < 2 then + return "Pathfinder failed!" + end + local course = Course.createFromAnalyticPath(vehicle, result.path, true) + vehicle:setFieldWorkCourse(course) + end + local success, ret = CpUtil.try(cmd) + if not success or ret then + CpUtil.info(ret) + end +end + +function Graph:consoleCommandGenerateSegmentsFromSplines() + if self._hasGeneratedSplines then + CpUtil.info("Already generated from road splines!") + return + end + local function isSplineEqual(segA, segB) + local snA1 = segA:getChildNodeByIndex(1) + local enA1 = segA:getChildNodeByIndex(segA:getNumChildNodes()) + local snA2 = segA:getChildNodeByIndex(2) + local enA2 = segA:getChildNodeByIndex(segA:getNumChildNodes() - 1) + + local snB1 = segB:getChildNodeByIndex(1) + local enB1 = segB:getChildNodeByIndex(segB:getNumChildNodes()) + local snB2 = segB:getChildNodeByIndex(2) + local enB2 = segB:getChildNodeByIndex(segB:getNumChildNodes() - 1) + local margin = 3 + if (snA1:getDistance2DToPoint(enB1) <= margin or + snA1:getDistance2DToPoint(enB2) <= margin or + snA2:getDistance2DToPoint(enB1) <= margin or + snA2:getDistance2DToPoint(enB2) <= margin) and + (snB1:getDistance2DToPoint(enA1) <= margin or + snB1:getDistance2DToPoint(enA2) <= margin or + snB2:getDistance2DToPoint(enA1) <= margin or + snB2:getDistance2DToPoint(enA2) <= margin) then + + return true + end + end + + local splineToCount = {} + for spline, _ in pairs(g_currentMission.aiSystem:getRoadSplines()) do + splineToCount[spline] = 0 + local sx, _, sz = getSplinePosition(spline, 0) + local ex, _, ez = getSplinePosition(spline, 0) + local length = getSplineLength(spline) + for otherSpline, _ in pairs(g_currentMission.aiSystem:getRoadSplines()) do + if spline ~= otherSpline then + local dsx, _, dsz = getSplinePosition(otherSpline, 0) + local dex, _, dez = getSplinePosition(otherSpline, 0) + if MathUtil.vector2Length(sx - dsx, sz - dsz) < 1 or + MathUtil.vector2Length(sx - dex, sz - dez) < 1 or + MathUtil.vector2Length(ex - dsx, ez - dsz) < 1 or + MathUtil.vector2Length(ex - dex, ez - dez) < 1 then + if length < getSplineLength(otherSpline) then + splineToCount[spline] = splineToCount[spline] + 1 + end + end + end + end + end + local splineSegments = {} + for spline, count in pairs(splineToCount) do + local ignoreSpline = count > 0 + local length = getSplineLength(spline) + local segment = GraphSegment(true) + if not ignoreSpline then + for i = 0, 1, 6/length do + local posX, posY, posZ = getSplinePosition(spline, i) + local point = GraphPoint() + point:setPosition(posX, posY, posZ) + segment:appendChildNode(point) + end + if segment:getNumChildNodes() > 1 then + for _, seg in ipairs(splineSegments) do + if isSplineEqual(segment, seg) then + seg:changeDirection(GraphSegmentDirection.DUAL) + ignoreSpline = true + break + end + end + if not ignoreSpline then + table.insert(splineSegments, segment) + end + end + end + end + self:extendByChildNodes(splineSegments, false) + self._hasGeneratedSplines = true +end + +function Graph:setup() + ---@type GraphPlot + self._ingameMapPlot = GraphPlot(self) + if self._loadedMapId ~= g_currentMission.missionInfo.mapId then + --- TODO Show warning, as the loaded graph map id is different! + end +end + +function Graph.registerXmlSchema(xmlSchema, baseKey) + GraphSegment.registerXmlSchema(xmlSchema, + baseKey .. Graph.XML_KEY .. ".") + xmlSchema:register(XMLValueType.STRING, + baseKey .. Graph.XML_KEY .. "#mapId", + "The savegame map id to which the graph is assigned.") + xmlSchema:register(XMLValueType.BOOL, + baseKey .. Graph.XML_KEY .. "#hasGeneratedSplines", + "Has generated splines?", false) +end + +function Graph:loadFromXMLFile(xmlFile, baseKey) + xmlFile:iterate(baseKey .. self.XML_KEY .. "." .. GraphSegment.XML_KEY, function (ix, key) + local segment = GraphSegment() + segment:loadFromXMLFile(xmlFile, key) + self:appendChildNode(segment) + end) + self._hasGeneratedSplines = xmlFile:getValue( + baseKey .. self.XML_KEY .. "#hasGeneratedSplines", false) + self._loadedMapId = xmlFile:getValue( + baseKey .. self.XML_KEY .. "#mapId", "") + -- TODO fix old unique target ids to new ids .. + +end + +function Graph:saveToXMLFile(xmlFile, baseKey) + for i, segment in ipairs(self._childNodes) do + segment:saveToXMLFile(xmlFile, string.format("%s.%s(%i)", + baseKey .. self.XML_KEY, GraphSegment.XML_KEY, i - 1)) + end + xmlFile:setValue(baseKey .. self.XML_KEY .. "#hasGeneratedSplines", + self._hasGeneratedSplines or false) + xmlFile:setValue(baseKey .. self.XML_KEY .. "#mapId", + g_currentMission.missionInfo.mapId or "") +end + +---@param node GraphNode +function Graph:onAddedChildNode(node) + GraphNode.onAddedChildNode(self, node) + +end + +---@param node GraphNode +function Graph:onRemovedChildNode(node) + GraphNode.onRemovedChildNode(self, node) + +end + +---@param hoveredNodeID string|nil +---@param selectedNodeIDs table|nil +function Graph:draw(hoveredNodeID, selectedNodeIDs) + for i, segment in ipairs(self._childNodes) do + segment:draw(hoveredNodeID, selectedNodeIDs) + end +end + +function Graph:drawMap(map) + self._ingameMapPlot:draw(map) +end + +function Graph:update(dt) + +end + +function Graph:writeStream(streamId, connection) + streamWriteUInt32(streamId, self:getNumChildNodes()) + for segment in ipairs(self._childNodes) do + segment:writeStream(streamId, connection) + end + streamWriteBool(streamId, self._hasGeneratedSplines or false) + streamWriteString(streamId, self._loadedMapId or "") +end + +function Graph:readStream(streamId, connection) + local numElements = streamReadUInt32(streamId) + for i=1, numElements do + local segment = GraphSegment() + segment:readStream(streamId, connection) + self:appendChildNode(segment) + end + self._hasGeneratedSplines = streamReadBool(streamId) + self._loadedMapId = streamReadString(streamId) +end + +---@return GraphSegment[] +function Graph:getSegments() + return self._childNodes +end + +---@return GraphPoint[] +function Graph:getAllPoints() + local points = {} + for _, segment in ipairs(self._childNodes) do + for _, point in ipairs(segment:getPoints()) do + table.insert(points, point) + end + end + return points +end + +---@param index string|nil +---@return GraphPoint|nil +function Graph:getPointByIndex(index) + if index == nil then + return + end + local _, _, ix, jx = string.find(index, "(%d+).(%d+)") + if ix ~= nil and jx ~= nil then + local segment = self:getChildNodeByIndex(tonumber(ix)) + if segment then + local point = segment:getChildNodeByIndex(tonumber(jx)) + if point then + return point + else + CpUtil.info("Failed to get Graph segment(%d) point: %d", ix, jx) + end + else + CpUtil.info("Failed to get Graph segment for: %d", ix) + end + else + CpUtil.info("Failed to get Graph index: %s", index) + end +end + +function Graph:addNewSegment(segment, noEventSend) + self:appendChildNode(segment) + if not noEventSend then + GraphCreateSegmentEvent.sendEvent(segment) + end + return segment +end + +---@param x number +---@param y number +---@param z number +---@return GraphSegment +function Graph:createSegmentWithPoint(x, y, z) + local segment = GraphSegment() + local point = GraphPoint() + point:setPosition(x, y, z) + segment:appendChildNode(point) + self:addNewSegment(segment) + return segment +end + +---@param segment GraphSegment +---@param noEventSend boolean|nil +function Graph:removeSegment(segment, noEventSend) + if not noEventSend then + GraphRemoveSegmentEvent.sendEvent(segment) + end + segment:clearChildNodes() + segment:unlink() +end + +---@param target GraphTarget +function Graph:onTargetCreated(target) + table.insert(self._targets, target) +end + +---@param target GraphTarget +function Graph:onTargetDeleted(target) + local ixToRemove + for i=#self._targets, 1, -1 do + if self._targets[i] == target then + ixToRemove = i + break + end + end + table.remove(self._targets, ixToRemove) +end + +function Graph:getAllTargets() + return self._targets +end + +function Graph:getNumTargets() + return #self._targets +end + +---@param id number +---@return GraphTarget|nil +function Graph:getTargetByUniqueID(id) + for _, target in ipairs(self._targets) do + if target:getUniqueID() == id then + return target + end + end +end + +---@param index number +---@return GraphTarget|nil +function Graph:getTargetByIndex(index) + return self._targets[index] +end + +---@param id number +---@return number index is -1 if not found! +function Graph:getTargetIndexByUniqueId(id) + for index, target in ipairs(self._targets) do + if target:getUniqueID() == id then + return index + end + end + return -1 +end + +---@return GraphPathfinder.GraphEdge[] +function Graph:getGraphEdges() + local edges = {} + for _, seg in ipairs(self._childNodes) do + local edge = seg:toGraphEdge() + table.insert(edges, edge) + end + return edges +end + +-- clean up for script reload +local savedGraph +if g_graph then + savedGraph = g_graph + g_graph:delete() +end + +---@type Graph +g_graph = Graph() + +if savedGraph then + -- TODO: there are more dependencies somewhere on the UI so this may not work completely + g_graph._childNodes = savedGraph._childNodes + g_graph._targets = savedGraph._targets + g_graph._hasGeneratedSplines = savedGraph._hasGeneratedSplines +end \ No newline at end of file diff --git a/scripts/graph/GraphNode.lua b/scripts/graph/GraphNode.lua new file mode 100644 index 000000000..1692501cb --- /dev/null +++ b/scripts/graph/GraphNode.lua @@ -0,0 +1,280 @@ +---@class GraphNode +GraphNode = CpObject() +function GraphNode:init() + self._id = -1 + ---@type GraphNode[] + self._childNodes = {} + ---@type GraphNode|nil + self._parentNode = nil +end + +---@param id number +function GraphNode:setID(id) + self._id = id +end + +---@return number +function GraphNode:getID() + return self._id +end + +---@return string +function GraphNode:getRelativeID() + if self._parentNode then + return string.format("%s.%s", self._parentNode:getID(), self._id) + end + return "?." .. tostring(self._id) +end + +function GraphNode:decrementID() + self._id = self._id - 1 +end + +function GraphNode:incrementID() + self._id = self._id + 1 +end + +function GraphNode:repairIDs() + for i, node in ipairs(self._childNodes) do + node:setID(i) + end +end + +---@return boolean +function GraphNode:isValid() + return self._id >= 0 +end + +function GraphNode:setInvalid() + self._id = -1 +end + +---@return GraphNode +function GraphNode:getParentNode() + return self._parentNode +end + +---@param node GraphNode|nil +function GraphNode:setParent(node) + self._parentNode = node +end + +---@return boolean +function GraphNode:isFirstNode() + if not self._parentNode then + return true + end + local index = self._parentNode:getChildNodeIndex(self) + return index == 1 +end + +---@return boolean +function GraphNode:isLastNode() + if not self._parentNode then + return false + end + local index = self._parentNode:getChildNodeIndex(self) + return index == self._parentNode:getNumChildNodes() +end + +function GraphNode:hasChildNodes() + return #self._childNodes > 0 +end + +---@param childNode GraphNode|nil +---@return number|nil +function GraphNode:getChildNodeIndex(childNode) + for ix, node in ipairs(self._childNodes) do + if node == childNode then + return ix + end + end +end + +---@param index number|nil +---@return GraphNode|nil +function GraphNode:getChildNodeByIndex(index) + if index ~= nil then + return self._childNodes[index] + end +end + +---@param sx number +---@param ex number +---@return GraphNode[] +function GraphNode:getChildNodesBetweenIndex(sx, ex) + local nodes = {} + for ix, node in ipairs(self._childNodes) do + if ix >= sx and ix <= ex then + table.insert(nodes, node) + end + end + return nodes +end + +---@return number +function GraphNode:getNumChildNodes() + return #self._childNodes +end + +---@return GraphNode[] +function GraphNode:getAllChildNodes() + return self._childNodes +end + +---@return string|nil +function GraphNode:getLastNodeID() + local node = self._childNodes[#self._childNodes] + return node and node:getRelativeID() +end + +--- Unlink the node from the parent and remove it from it's children +---@param successCallback function|nil +---@return boolean +function GraphNode:unlink(successCallback) + local success = false + if self._parentNode then + local parentNode = self._parentNode + if self._parentNode:removeChildNode(self) then + success = true + if successCallback then + successCallback(self, parentNode) + end + end + end + return success +end + +---@param newNode GraphNode +function GraphNode:appendChildNode(newNode) + table.insert(self._childNodes, newNode) + newNode:setID(#self._childNodes) + self:onAddedChildNode(newNode) +end + +---@param newNode GraphNode +---@param reverse boolean +function GraphNode:extendByChildren(newNode, reverse) + self:extendByChildNodes(newNode:getAllChildNodes(), reverse) +end + +---@param newNodes GraphNode[] +---@param reverse boolean +function GraphNode:extendByChildNodes(newNodes, reverse) + local ix, l, dx = 1, #newNodes, 1 + if reverse then + ix, l, dx = #newNodes, 1, -1 + end + for i=ix, l, dx do + self:appendChildNode(newNodes[i]:clone(true)) + end +end + +---@param newNode GraphNode +---@param reverse boolean +function GraphNode:prepandByChildren(newNode, reverse) + local ix, l, dx = 1, newNode:getNumChildNodes(), 1 + if reverse then + ix, l, dx = newNode:getNumChildNodes(), 1, -1 + end + for i=ix, l, dx do + self:insertChildNodeAtIndex( + newNode:getChildNodeByIndex(i):clone(true), 1) + end +end + +---@param newNode GraphNode +---@param index number|nil [1, #self._childNodes + 1] +function GraphNode:insertChildNodeAtIndex(newNode, index) + if index == nil then + return + end + table.insert(self._childNodes, index, newNode) + newNode:setID(index) + for ix=index + 1, #self._childNodes do + self._childNodes[ix]:incrementID() + end + self:onAddedChildNode(newNode) + return newNode:getRelativeID() +end + +---@param node GraphNode +function GraphNode:onAddedChildNode(node) + node:setParent(self) +end + +---@param sx number +---@param ex number +function GraphNode:removeChildNodesBetweenIndex(sx, ex) + for ix = math.min(ex, #self._childNodes), sx, -1 do + local n = table.remove(self._childNodes, ix) + n:setInvalid() + self:onRemovedChildNode(n) + end + self:repairIDs() +end + +---@param oldNode GraphNode +function GraphNode:removeChildNode(oldNode) + local found = false + for ix, point in ipairs(self._childNodes) do + if point == oldNode then + self:removeChildNodeAtIndex(ix) + found = true + break + end + end + return found +end + +---@param index number +---@return boolean, GraphNode|nil +function GraphNode:removeChildNodeAtIndex(index) + local n = table.remove(self._childNodes, index) + if n ~= nil then + for ix = index, #self._childNodes do + self._childNodes[ix]:decrementID() + end + n:setInvalid() + self:onRemovedChildNode(n) + end + return n ~= nil, n +end + +---@param node GraphNode +function GraphNode:onRemovedChildNode(node) + node:setParent(nil) +end + +function GraphNode:clearChildNodes() + for i = #self._childNodes, 1, -1 do + self._childNodes[i]:unlink() + end +end + +---@param newNode GraphNode +---@param unlink boolean|nil +function GraphNode:copyTo(newNode, unlink) + if not unlink then + newNode._parentNode = self._parentNode + end + for _, node in ipairs(self._childNodes) do + newNode:appendChildNode(node:clone(unlink)) + end +end + +---@param unlink boolean|nil +function GraphNode:clone(unlink) + --- Override +end + +---@return string[] +function GraphNode:getDebugInfos() + local data = {string.format("ID: %s", self:getRelativeID())} + if self._parentNode then + for ix, l in ipairs(self._parentNode:getDebugInfos()) do + data[ix + 1] = l + end + end + return data +end \ No newline at end of file diff --git a/scripts/graph/GraphPoint.lua b/scripts/graph/GraphPoint.lua new file mode 100644 index 000000000..df8ffa894 --- /dev/null +++ b/scripts/graph/GraphPoint.lua @@ -0,0 +1,253 @@ +---@class GraphPoint : GraphNode +---@field _parentNode GraphSegment +GraphPoint = CpObject(GraphNode) +GraphPoint.XML_KEY = "Point" +function GraphPoint:init() + GraphNode.init(self) + self._x = 0 + self._y = 0 + self._z = 0 + ---@type GraphTarget|nil + self._target = nil +end + +function GraphPoint.registerXmlSchema(xmlSchema, baseKey) + local key = baseKey .. GraphPoint.XML_KEY .. "(?)" + GraphTarget.registerXmlSchema(xmlSchema, key) + xmlSchema:register(XMLValueType.VECTOR_3, + key .. "#pos", + "Position", {0,0,0}) + xmlSchema:register(XMLValueType.BOOL, + key .. "#hasTarget", + "Has associated Target?", false) +end + +function GraphPoint:loadFromXMLFile(xmlFile, key) + local pos = xmlFile:getValue(key .. "#pos", + self._x ,self._y, self._z) + if pos then + self._x, self._y, self._z = unpack(pos) + end + if xmlFile:getValue(key .. "#hasTarget", false) then + self._target = GraphTarget(self) + self._target:loadFromXMLFile(xmlFile, key) + end +end + +function GraphPoint:saveToXMLFile(xmlFile, key) + xmlFile:setValue(key .. "#pos", self._x, self._y, self._z) + if self._target then + xmlFile:setValue(key .. "#hasTarget", true) + self._target:saveToXMLFile(xmlFile, key) + end +end + +function GraphPoint:writeStream(streamId, connection) + streamWriteFloat32(streamId, self._x) + streamWriteFloat32(streamId, self._y) + streamWriteFloat32(streamId, self._z) + streamWriteBool(streamId, self._target ~= nil) + if self._target then + self._target:writeStream(streamId, connection) + end +end + +function GraphPoint:readStream(streamId, connection) + self._x = streamReadFloat32(streamId) + self._y = streamReadFloat32(streamId) + self._z = streamReadFloat32(streamId) + if streamReadBool(streamId) then + local target = GraphTarget(self) + target:readStream(streamId, connection) + end +end + +---@param newNode GraphPoint +---@param unlink boolean|nil +function GraphPoint:copyTo(newNode, unlink) + GraphNode.copyTo(self, newNode, unlink) + if self._target then + if unlink then + newNode._target = GraphTarget() + self._target:copyTo(newNode._target) + else + newNode._target = self._target + end + end + newNode._x = self._x + newNode._y = self._y + newNode._z = self._z +end + +---@return GraphPoint +function GraphPoint:clone(unlink) + local newPoint = GraphPoint() + self:copyTo(newPoint, unlink) + return newPoint +end + +---@param hoveredNodeID string|nil +---@param selectedNodeIDs table|nil +---@param isTemporary boolean|nil +function GraphPoint:draw(hoveredNodeID, selectedNodeIDs, isTemporary) + local color = Color.new(0, 0, 1) + if hoveredNodeID == self:getRelativeID() then + color = Color.new(1, 1, 1) + elseif selectedNodeIDs ~= nil and selectedNodeIDs[self:getRelativeID()] ~= nil then + color = Color.new(1, 1, 0) + elseif isTemporary then + color = Color.new(0, 1, 0) + end + DebugUtil.drawDebugSphere(self._x, self._y + 0.5, self._z, + 1, 6, 6, color, false, false) + local data = self:getDebugInfos() + local yOffset = 0 + for _, line in ipairs(data) do + Utils.renderTextAtWorldPosition(self._x, self._y + 1, self._z, + line, getCorrectTextSize(0.012), yOffset) + yOffset = yOffset + getCorrectTextSize(0.012) + end +end + +function GraphPoint:getDebugInfos() + local data = GraphNode.getDebugInfos(self) + if self._target then + data[#data + 1] = string.format("Target: %s", self._target:getName()) + end + return data +end + +---@param x number +---@param y number +---@param z number +function GraphPoint:setPosition(x, y, z) + self._x = x + self._y = y + self._z = z +end + +---@param x number +---@param z number +function GraphPoint:setPosition2D(x, z) + self._x = x + if getTerrainHeightAtWorldPos then + self._y = getTerrainHeightAtWorldPos(g_currentMission.terrainRootNode, x, 0, z) + else + self._y = 0 + end + self._z = z +end + +---@return number, number, number +function GraphPoint:getPosition() + return self._x, self._y, self._z +end + +---@return number, number +function GraphPoint:getPosition2D() + return self._x, self._z +end + +---@param dx number +---@param dy number +---@param dz number +function GraphPoint:move(dx, dy, dz) + if dx == nil or dy == nil or dz == nil then + return + end + self._x = self._x + dx + self._y = self._y + dy + self._z = self._z + dz +end + +---@param dx number +---@param dz number +function GraphPoint:move2D(dx, dz) + if dx == nil or dz == nil then + return + end + self._x = self._x + dx + self._z = self._z + dz +end + +---@param dx number +---@param dy number +---@param dz number +function GraphPoint:moveTo(dx, dy, dz) + if dx == nil or dy == nil or dz == nil then + return + end + self._x = dx + self._y = dy + self._z = dz +end + +---@param dx number +---@param dz number +function GraphPoint:moveTo2D(dx, dz) + if dx == nil or dz == nil then + return + end + self._x = dx + self._z = dz +end + +---@param other GraphPoint +---@return number +function GraphPoint:getDistance2DToPoint(other) + local dx, dz = other:getPosition2D() + return MathUtil.vector2Length(self._x - dx, self._z - dz) +end + +---@param dx number +---@param dz number +---@return number +function GraphPoint:getDistance2DTo(dx, dz) + return MathUtil.vector2Length(self._x - dx, self._z - dz) +end + +function GraphPoint:toVector() + return Vector(self._x, -self._z) +end + +----------------------------- +--- Target +----------------------------- + +---@return boolean +function GraphPoint:hasTarget() + return self._target ~= nil +end + +---@return GraphTarget|nil +function GraphPoint:getTarget() + return self._target +end + +---@param name string +---@param noEventSend boolean|nil +---@return boolean +function GraphPoint:createTarget(name, noEventSend) + if self:hasTarget() then + return false + end + if not noEventSend then + GraphCreateTargetEvent.sendEvent(self, name) + end + self._target = GraphTarget(self, name) + return true +end + +---@param noEventSend boolean|nil +---@return boolean +function GraphPoint:removeTarget(noEventSend) + if not self:hasTarget() then + return false + end + if not noEventSend then + GraphRemoveTargetEvent.sendEvent(self) + end + self._target:delete() + self._target = nil + return true +end \ No newline at end of file diff --git a/scripts/graph/GraphSegment.lua b/scripts/graph/GraphSegment.lua new file mode 100644 index 000000000..9ee370cae --- /dev/null +++ b/scripts/graph/GraphSegment.lua @@ -0,0 +1,244 @@ +---@class GraphSegmentDirection +GraphSegmentDirection = {} +GraphSegmentDirection.FORWARD = 1 +GraphSegmentDirection.REVERSE = 2 +GraphSegmentDirection.DUAL = 3 +GraphSegmentDirection.MAX_KEY = GraphSegmentDirection.DUAL +GraphSegmentDirection.COLORS = { + [GraphSegmentDirection.FORWARD] = {0.0742, 0.4341, 0.6939, 1}, + [GraphSegmentDirection.REVERSE] = {0.0284, 0.0284, 0.0284, 1}, + [GraphSegmentDirection.DUAL] = {0.8, 0.4, 0, 1}, +} +GraphSegmentDirection.DEBUG_TEXTS = { + [GraphSegmentDirection.FORWARD] = "Forward", + [GraphSegmentDirection.REVERSE] = "Reverse", + [GraphSegmentDirection.DUAL] = "Dual", +} + +---@class GraphSegment : GraphNode +---@field _childNodes GraphPoint[] +GraphSegment = CpObject(GraphNode) +GraphSegment.XML_KEY = "Segment" +GraphSegment.DRAW_CAMERA_RANGE = 125 +function GraphSegment:init(isGeneratedBySpline) + GraphNode.init(self) + self._direction = GraphSegmentDirection.FORWARD + ---@type boolean + self._isGeneratedBySpline = isGeneratedBySpline or false +end + +function GraphSegment.registerXmlSchema(xmlSchema, baseKey) + local key = baseKey .. GraphSegment.XML_KEY + xmlSchema:register(XMLValueType.INT, + key .. "(?)#direction", + "Current direction", GraphSegmentDirection.FORWARD) + xmlSchema:register(XMLValueType.BOOL, + key .. "(?)#isGeneratedBySpline", + "Was generated from splines?", false) + GraphPoint.registerXmlSchema(xmlSchema, key .. "(?).") +end + +function GraphSegment:loadFromXMLFile(xmlFile, baseKey) + self._direction = xmlFile:getValue(baseKey .. "#direction", GraphSegmentDirection.FORWARD) + self._isGeneratedBySpline = xmlFile:getValue(baseKey .. "#isGeneratedBySpline", false) + xmlFile:iterate(baseKey .. "." .. GraphPoint.XML_KEY, function (ix, key) + local point = GraphPoint() + point:loadFromXMLFile(xmlFile, key) + self:appendChildNode(point) + end) +end + +function GraphSegment:saveToXMLFile(xmlFile, baseKey) + xmlFile:setValue(baseKey .. "#direction", self._direction) + xmlFile:setValue(baseKey .. "#isGeneratedBySpline", self._isGeneratedBySpline) + for i, point in ipairs(self._childNodes) do + local key = string.format("%s.%s(%d)", baseKey, GraphPoint.XML_KEY, i - 1) + point:saveToXMLFile(xmlFile, key) + end +end + +function GraphSegment:writeStream(streamId, connection) + streamWriteUInt32(streamId, self:getNumChildNodes()) + for point in ipairs(self._childNodes) do + point:writeStream(streamId, connection) + end + self:writeStreamAttributes(streamId, connection) +end + +function GraphSegment:writeStreamAttributes(streamId, connection) + streamWriteUInt8(streamId, self._direction) + streamWriteBool(streamId, self._isGeneratedBySpline or false) +end + +function GraphSegment:readStream(streamId, connection) + local numElements = streamReadUInt32(streamId) + for i=1, numElements do + local point = GraphPoint() + point:readStream(streamId, connection) + self:appendChildNode(point) + end + self:readStreamAttributes(streamId, connection) +end + +function GraphSegment:readStreamAttributes(streamId, connection) + self._direction = streamReadUInt8(streamId) + self._isGeneratedBySpline = streamReadBool(streamId) +end + +---@param newNode GraphSegment +---@param unlink boolean|nil +function GraphSegment:copyTo(newNode, unlink) + GraphNode.copyTo(self, newNode, unlink) + newNode._direction = self._direction +end + +---@return GraphSegment +---@param unlink boolean|nil +function GraphSegment:clone(unlink) + local newSegment = GraphSegment() + self:copyTo(newSegment, unlink) + return newSegment +end + +---@param hoveredNodeID string|nil +---@param selectedNodeIDs table|nil +---@param isTemporary boolean|nil +---@param temporaryPrevPoint GraphPoint|nil +function GraphSegment:draw(hoveredNodeID, selectedNodeIDs, isTemporary, temporaryPrevPoint) + local prevPoint = temporaryPrevPoint + for _, point in ipairs(self._childNodes) do + local x, y, z = point:getPosition() + if DebugUtil.isPositionInCameraRange(x, y, z, self.DRAW_CAMERA_RANGE) then + point:draw(hoveredNodeID, selectedNodeIDs, isTemporary) + self:drawLineBetween(prevPoint, point) + end + prevPoint = point + end +end + +---@param prevPoint GraphPoint|nil +---@param point GraphPoint +function GraphSegment:drawLineBetween(prevPoint, point) + if prevPoint then + local color = {0, 0.5, 1} + local x, y, z = point:getPosition() + local dx, dy, dz = prevPoint:getPosition() + DebugUtil.drawDebugLine(x, y + 2, z, + dx, dy + 0.5, dz, unpack(color), 2) + local dist = MathUtil.vector3Length(x - dx, y - dy, z - dz) + if dist > 1 then + local nx, _, nz = MathUtil.vector3Normalize(x - dx, y - dy, z - dz) + local delta = 6 + local numArrows = dist / delta + 1 + local spacing = dist / (numArrows + 1) + if self._direction == GraphSegmentDirection.REVERSE then + nz = -1 * nz + nx = -1 * nx + end + for i = spacing/2, dist, spacing do + if self._direction == GraphSegmentDirection.FORWARD or + self._direction == GraphSegmentDirection.REVERSE then + + local tx, tz = dx + nx * i, dz + nz * i + if self._direction == GraphSegmentDirection.REVERSE then + tx, tz = x + nx * i, z + nz * i + end + local ncx = nx * math.cos(math.pi/4) - nz * math.sin(math.pi/4) + local ncz = nx * math.sin(math.pi/4) + nz * math.cos(math.pi/4) + DebugUtil.drawDebugLine(tx, y + 0.5, tz, + tx - ncx * 2, y + 0.5, tz - ncz * 2, unpack(color)) + ncx = nx * math.cos(-math.pi/4) - nz * math.sin(-math.pi/4) + ncz = nx * math.sin(-math.pi/4) + nz * math.cos(-math.pi/4) + DebugUtil.drawDebugLine(tx, y + 0.5, tz, + tx - ncx * 2, y + 0.5, tz - ncz * 2, unpack(color)) + elseif self._direction == GraphSegmentDirection.DUAL then + -- x, y, z, radius, steps, color, alignToTerrain, filled + DebugUtil.drawDebugCircle(dx + nx * i, y + 0.5, dz + nz * i, + 1, 8, color) + end + end + end + end +end + +---@return table +function GraphSegment:getDebugInfos() + return {string.format("Direction: %s", self:getDirectionString())} +end + +---@return GraphPoint[] +function GraphSegment:getPoints() + return self._childNodes +end + +---@param newDirection number|nil +function GraphSegment:changeDirection(newDirection) + if newDirection == nil then + newDirection = self._direction + 1 + if newDirection > GraphSegmentDirection.MAX_KEY then + newDirection = 1 + end + end + self:setDirection(newDirection) +end + +---@param newDirection number +---@param noEventSend boolean|nil +function GraphSegment:setDirection(newDirection, noEventSend) + self._direction = newDirection + if not noEventSend then + GraphSegmentChangedAttributesEvent.sendEvent(self) + end +end + +---@return string +function GraphSegment:getDirectionString() + return GraphSegmentDirection.DEBUG_TEXTS[self._direction] or "???" +end + +---@return number +---@return number +---@return number +---@return number +function GraphSegment:getDirectionColor() + return GraphSegmentDirection.COLORS[self._direction] or 0,0,0,1 +end + +---@return number +function GraphSegment:getLength() + local length = 0 + for ix, node in ipairs(self._childNodes) do + if ix > 1 then + length = length + node:getDistance2DToPoint(self._childNodes[ix-1]) + end + end + return length +end + +---@return boolean +function GraphSegment:isReverse() + return self._direction == GraphSegmentDirection.REVERSE +end + +---@return boolean +function GraphSegment:isDual() + return self._direction == GraphSegmentDirection.DUAL +end + +function GraphSegment:toGraphEdge() + local points = {} + local sx, ex, inc = 1, #self._childNodes, 1 + if self:isReverse() then + sx, ex, inc = #self._childNodes, 1, -1 + end + for i = sx, ex, inc do + table.insert(points, self._childNodes[i]:toVector()) + end + if self:isDual() then + return GraphPathfinder.GraphEdge( + GraphPathfinder.GraphEdge.BIDIRECTIONAL, points) + else + return GraphPathfinder.GraphEdge( + GraphPathfinder.GraphEdge.UNIDIRECTIONAL, points) + end +end \ No newline at end of file diff --git a/scripts/graph/GraphTarget.lua b/scripts/graph/GraphTarget.lua new file mode 100644 index 000000000..ab2e61be0 --- /dev/null +++ b/scripts/graph/GraphTarget.lua @@ -0,0 +1,81 @@ +---@class GraphTarget +GraphTarget = CpObject() +GraphTarget.uniqueID = 0 +function GraphTarget.getNextUniqueID() + GraphTarget.uniqueID = GraphTarget.uniqueID + 1 + return GraphTarget.uniqueID +end + +function GraphTarget:init(point, name) + ---@type GraphPoint + self._point = point + self._name = name or "???" + self._uniqueID = GraphTarget.getNextUniqueID() + g_graph:onTargetCreated(self) +end + +function GraphTarget:delete() + g_graph:onTargetDeleted(self) +end + +function GraphTarget.registerXmlSchema(xmlSchema, baseKey) + xmlSchema:register(XMLValueType.STRING, baseKey .. "#name", "Target name") + xmlSchema:register(XMLValueType.INT, baseKey .. "#oldUniqueID", "Old unique ID") +end + +function GraphTarget:loadFromXMLFile(xmlFile, baseKey) + self._name = xmlFile:getValue(baseKey .. "#name", "") + self._oldUniqueID = xmlFile:getValue(baseKey .. "#oldUniqueID", -1) +end + +function GraphTarget:saveToXMLFile(xmlFile, baseKey) + xmlFile:setValue(baseKey .. "#name", self._name) + xmlFile:setValue(baseKey .. "#oldUniqueID", self._uniqueID) +end + +function GraphTarget:writeStream(streamId, connection) + streamWriteString(streamId, self._name) +end + +function GraphTarget:readStream(streamId, connection) + self._name = streamReadString(streamId) +end + +---@param otherTarget GraphTarget +function GraphTarget:copyTo(otherTarget) + otherTarget._name = self._name +end + +function GraphTarget:getUniqueID() + return self._uniqueID +end + +---@return string +function GraphTarget:getName() + return self._name +end + +---@param name string +function GraphTarget:setName(name) + self._name = name +end + +---@param name string +---@param noEventSend boolean|nil +function GraphTarget:rename(name, noEventSend) + self:setName(name) + if not noEventSend then + GraphRenameTargetEvent.sendEvent(self._point, name) + end +end + +---@return Vector +function GraphTarget:toVector() + local x, z = self._point:getPosition2D() + return Vector(x, -z) +end + +---@return GraphPoint +function GraphTarget:getPoint() + return self._point +end \ No newline at end of file diff --git a/scripts/gui/CpInGameMenu.lua b/scripts/gui/CpInGameMenu.lua index e971d9c84..777492241 100644 --- a/scripts/gui/CpInGameMenu.lua +++ b/scripts/gui/CpInGameMenu.lua @@ -219,10 +219,9 @@ function CpInGameMenu:setupMenuPages() end function CpInGameMenu:isContructionPageAvailiable() - return g_courseEditor:getIsActive() + return g_courseEditor:getIsActive() or g_graphEditor:getIsActive() end - function CpInGameMenu:setupMenuButtonInfo() CpInGameMenu:superClass().setupMenuButtonInfo(self) local onButtonBackFunction = self.clickBackCallback diff --git a/scripts/gui/dialog/FilltypeSelectionDialog.lua b/scripts/gui/dialog/FilltypeSelectionDialog.lua new file mode 100644 index 000000000..580777797 --- /dev/null +++ b/scripts/gui/dialog/FilltypeSelectionDialog.lua @@ -0,0 +1,109 @@ +---@class FilltypeSelectionDialog +FilltypeSelectionDialog = {} +local FilltypeSelectionDialog_mt = Class(FilltypeSelectionDialog, MessageDialog) +function FilltypeSelectionDialog.new(target, customMt) + local self = MessageDialog.new(target, customMt or FilltypeSelectionDialog_mt) + self.setting = nil + self.callbackFunc = nil + return self +end + +function FilltypeSelectionDialog.register() + FilltypeSelectionDialog.INSTANCE = FilltypeSelectionDialog.new() + g_gui:loadGui(Utils.getFilename("config/gui/dialog/FilltypeSelectionDialog.xml", + g_Courseplay.BASE_DIRECTORY), "FilltypeSelectionDialog", FilltypeSelectionDialog.INSTANCE) +end + +function FilltypeSelectionDialog.show(setting, callbackFunc) + if FilltypeSelectionDialog.INSTANCE == nil then + return + end + local dialog = FilltypeSelectionDialog.INSTANCE + dialog:setSetting(setting) + dialog:setCallback(callbackFunc) + g_gui:showDialog("FilltypeSelectionDialog") + return dialog +end + +function FilltypeSelectionDialog:createFromExistingGui(_) + local settings = self.setting + local callbackFunc = self.callbackFunc + FilltypeSelectionDialog.register() + FilltypeSelectionDialog.show(settings, callbackFunc) +end + +function FilltypeSelectionDialog:onCreate() + self.fillTypeList:setDataSource(self) +end + +function FilltypeSelectionDialog:onOpen() + FilltypeSelectionDialog:superClass().onOpen(self) + self.setting:bindSettingsToGui(function(fillTypeSetting, fillType, maxFillLevel, minFillLevel, counter) + self.fillTypeList:reloadData() + self.fillTypeList:setSelectedIndex(fillType:getCurrentIndex()) + local function link(element, setting) + setting:refresh() + local option = element:getDescendantByName("option") + option:setDataSource(setting) + option.aiParameter = setting + option:setDisabled(not setting:getCanBeChanged()) + local title = element:getDescendantByName("title") + title:setText(setting:getTitle()) + end + link(self.maxFillLevel, maxFillLevel) + link(self.minFillLevel, minFillLevel) + link(self.counter, counter) + end) +end + +function FilltypeSelectionDialog:onClickOk() + if self.callbackFunc then + self.callbackFunc() + end + self:close() +end + +function FilltypeSelectionDialog:onClickDiscard() + if self.callbackFunc then + self.callbackFunc() + end + self:close() +end + +function FilltypeSelectionDialog:onClickCpMultiTextOption(_, guiElement) + -- CpSettingsUtil.updateGuiElementsBoundToSettings(guiElement.parent.parent, self.cpMenu:getCurrentVehicle()) +end + +---@param setting CpAIParameterFillTypeSetting +function FilltypeSelectionDialog:setSetting(setting) + self.setting = setting +end + +function FilltypeSelectionDialog:setCallback(callbackFunc) + self.callbackFunc = callbackFunc +end + +function FilltypeSelectionDialog:getNumberOfSections(list) + return 1 +end + +function FilltypeSelectionDialog:getTitleForSectionHeader(list, section) + +end + +function FilltypeSelectionDialog:getNumberOfItemsInSection(list, section) + return self.setting:getNumberOfItemsInSection(list, section) +end + +function FilltypeSelectionDialog:populateCellForItemInSection(list, section, index, cell) + self.setting:populateCellForItemInSection(list, section, index, cell) +end + +function FilltypeSelectionDialog:onClickList(list, section, index, listElement) + listElement.onClickCallback(self) +end + +function FilltypeSelectionDialog:onListSelectionChanged(list, section, index) + self.setting:onListSelectionChanged(list, section, index) +end + diff --git a/scripts/gui/dialog/TargetPointSelectionDialog.lua b/scripts/gui/dialog/TargetPointSelectionDialog.lua new file mode 100644 index 000000000..a3a865fb6 --- /dev/null +++ b/scripts/gui/dialog/TargetPointSelectionDialog.lua @@ -0,0 +1,122 @@ +---@class TargetPointSelectionDialog +TargetPointSelectionDialog = {} +local TargetPointSelectionDialog_mt = Class(TargetPointSelectionDialog, MessageDialog) +function TargetPointSelectionDialog.new(target, customMt) + local self = MessageDialog.new(target, customMt or TargetPointSelectionDialog_mt) + ---@type CpAIParameterTargetPoint[] + self.settings = {} + self.callbackFunc = nil + return self +end + +function TargetPointSelectionDialog.register() + TargetPointSelectionDialog.INSTANCE = TargetPointSelectionDialog.new() + g_gui:loadGui(Utils.getFilename("config/gui/dialog/TargetPointSelectionDialog.xml", + g_Courseplay.BASE_DIRECTORY), "TargetPointSelectionDialog", TargetPointSelectionDialog.INSTANCE) +end + +function TargetPointSelectionDialog.show(settings, callbackFunc) + if TargetPointSelectionDialog.INSTANCE == nil then + return + end + local dialog = TargetPointSelectionDialog.INSTANCE + -- dialog:setCallback(p9, p10) + dialog:setTargetPoints(settings) + dialog:setCallback(callbackFunc) + g_gui:showDialog("TargetPointSelectionDialog") + return dialog +end + +function TargetPointSelectionDialog:createFromExistingGui(_) + local settings = self.settings + local callbackFunc = self.callbackFunc + TargetPointSelectionDialog.register() + TargetPointSelectionDialog.show(settings, callbackFunc) +end + +function TargetPointSelectionDialog:onCreate() + for i=1, 3 do + self.lists[i]:setDataSource(self) + end +end + +function TargetPointSelectionDialog:onOpen() + TargetPointSelectionDialog:superClass().onOpen(self) + local onOpenList = function (list, setting, header) + list:reloadData(self) + list:setVisible(setting ~= nil and not setting:getIsDisabled()) + local ix = setting and g_graph:getTargetIndexByUniqueId(setting:getValue()) or 1 + list:setSelected(ix > -1 and ix or 1) + header:setVisible(setting~=nil) + header:setText(setting and setting:getTitle() or "??") + end + for i=1, 3 do + onOpenList(self.lists[i], self.settings[i], self.listHeaders[i]) + end +end + +function TargetPointSelectionDialog:onClickOk() + if self.callbackFunc then + self.callbackFunc() + end + self:close() +end + +function TargetPointSelectionDialog:onClickDiscard() + if self.callbackFunc then + self.callbackFunc() + end + self:close() +end + +function TargetPointSelectionDialog:setTargetPoints(settings) + self.settings = settings or {} +end + +function TargetPointSelectionDialog:setCallback(callbackFunc) + self.callbackFunc = callbackFunc +end + +function TargetPointSelectionDialog:getNumberOfSections(list) + return 1 +end + +function TargetPointSelectionDialog:getTitleForSectionHeader(list, section) + +end + +function TargetPointSelectionDialog:getNumberOfItemsInSection(list, section) + return g_graph:getNumTargets() +end + +function TargetPointSelectionDialog:applySettingValue(setting, target) + if setting and not setting:getIsDisabled() and target then + setting:setValue(target:getUniqueID()) + end +end + +function TargetPointSelectionDialog:populateCellForItemInSection(list, section, index, cell) + local target = g_graph:getTargetByIndex(index) + cell:getAttribute("title"):setText(target and target:getName() or "????") + cell.onClickCallback = function () + for i=1, 3 do + if list == self.lists[i] then + self:applySettingValue(self.settings[i], target) + end + end + end +end + +function TargetPointSelectionDialog:onClickList(list, section, index, listElement) + listElement.onClickCallback(self) +end + +function TargetPointSelectionDialog:onListSelectionChanged(list, section, index) + local target = g_graph:getTargetByIndex(index) + for i=1, 3 do + if list == self.lists[i] then + self:applySettingValue(self.settings[i], target) + end + end +end + diff --git a/scripts/gui/hud/CpBaleFinderHudPage.lua b/scripts/gui/hud/CpBaleFinderHudPage.lua index cbb3ddd06..348959e9d 100644 --- a/scripts/gui/hud/CpBaleFinderHudPage.lua +++ b/scripts/gui/hud/CpBaleFinderHudPage.lua @@ -12,12 +12,12 @@ end function CpBaleFinderHudPageElement:setupElements(baseHud, vehicle, lines, wMargin, hMargin) --- Tool offset x - self.toolOffsetXBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, 2, CpBaseHud.defaultFontSize, + self.toolOffsetXBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, CpBaseHud.numLines - 3, CpBaseHud.defaultFontSize, vehicle:getCpSettings().baleCollectorOffset) --- Bale finder fill type - local x, y = unpack(lines[3].left) - local xRight,_ = unpack(lines[3].right) + local x, y = unpack(lines[CpBaseHud.numLines - 4].left) + local xRight,_ = unpack(lines[CpBaseHud.numLines - 4].right) self.baleFinderFillTypeBtn = CpHudTextSettingElement.new(self, x, y, xRight, CpBaseHud.defaultFontSize) local callback = { @@ -28,13 +28,13 @@ function CpBaleFinderHudPageElement:setupElements(baseHud, vehicle, lines, wMarg self.baleFinderFillTypeBtn:setCallback(callback, callback) --- Bale progress of how much bales have bin worked on, similar to waypoint progress. - self.balesProgressBtn = baseHud:addRightLineTextButton(self, 4, CpBaseHud.defaultFontSize, + self.balesProgressBtn = baseHud:addRightLineTextButton(self, CpBaseHud.numLines - 5, CpBaseHud.defaultFontSize, function(vehicle) baseHud:openCourseManagerGui(vehicle) end, vehicle) --- Bale progress of how much bales have bin worked on, similar to waypoint progress. - local x, y = unpack(lines[4].left) + local x, y = unpack(lines[CpBaseHud.numLines - 5].left) self.balesProgressLabel = CpTextHudElement.new(self, x, y, CpBaseHud.defaultFontSize) self.balesProgressLabel:setTextDetails(g_i18n:getText("CP_baleFinder_balesLeftover")) diff --git a/scripts/gui/hud/CpBaseHud.lua b/scripts/gui/hud/CpBaseHud.lua index 6d4f57ac1..3adcf52f0 100644 --- a/scripts/gui/hud/CpBaseHud.lua +++ b/scripts/gui/hud/CpBaseHud.lua @@ -29,14 +29,14 @@ CpBaseHud.basePosition = { CpBaseHud.baseSize = { x = 360, - y = 230 + y = 290 } CpBaseHud.headerFontSize = 14 CpBaseHud.titleFontSize = 20 CpBaseHud.defaultFontSize = 16 -CpBaseHud.numLines = 8 +CpBaseHud.numLines = 10 --- Vertical + horizontal overlay alignment CpBaseHud.alignments = { @@ -127,6 +127,7 @@ function CpBaseHud:init(vehicle) self.siloLoaderWorkerLayout = self:addHudPage(CpSiloLoaderWorkerHudPageElement, vehicle) + self.streetWorkerLayout = self:addHudPage(CpStreetWorkerHudPageElement, vehicle) -------------------------------------- --- Header -------------------------------------- @@ -157,7 +158,7 @@ function CpBaseHud:init(vehicle) self.BASE_COLOR, self.alignments.bottomLeft) self.cpIcon = CpHudButtonElement.new(cpIconOverlay, self.baseHud) - local x, y = unpack(self.lines[8].left) + local x, y = unpack(self.lines[self.numLines].left) y = y - self.hMargin/4 self.cpIcon:setPosition(x, y) self.cpIcon:setCallback("onClickPrimary", self.vehicle, function (vehicle) @@ -165,7 +166,7 @@ function CpBaseHud:init(vehicle) end) --- Title - local x, y = unpack(self.lines[8].left) + local x, y = unpack(self.lines[self.numLines].left) x = x + cpIconWidth + self.wMargin/2 self.vehicleNameBtn = CpTextHudElement.new(self.baseHud ,x ,y + self.hMargin/8, self.defaultFontSize) self.vehicleNameBtn:setCallback("onClickPrimary", self.vehicle, @@ -173,12 +174,12 @@ function CpBaseHud:init(vehicle) self:openVehicleSettingsGui(self.vehicle) end) - self.selectedJobBtn = self:addLeftLineTextButton(self.baseHud, 7, self.defaultFontSize, + self.selectedJobBtn = self:addLeftLineTextButton(self.baseHud, self.numLines - 1, self.defaultFontSize, function (vehicle) vehicle:cpGetHudSelectedJobSetting():setNextItem() end, self.vehicle) - local x, y = unpack(self.lines[6].left) + local x, y = unpack(self.lines[self.numLines - 2].left) local spacerLineOverlay = Overlay.new('dataS/menu/base/graph_pixel.dds', x, y, self.width - 2 * self.wMargin, self.hMargin/8) spacerLineOverlay:setColor(unpack(self.OFF_COLOR)) @@ -227,7 +228,7 @@ function CpBaseHud:init(vehicle) self.alignments.bottomRight) self.onOffButton = CpHudButtonElement.new(onOffIndicatorOverlay, self.baseHud) - local x, y = unpack(self.lines[8].right) + local x, y = unpack(self.lines[self.numLines].right) self.onOffButton:setPosition(x, y - self.hMargin/8) self.onOffButton:setCallback("onClickPrimary", self.vehicle, function(vehicle) vehicle:cpStartStopDriver(true) @@ -241,7 +242,7 @@ function CpBaseHud:init(vehicle) self.OFF_COLOR, self.alignments.bottomRight) self.startStopRecordingBtn = CpHudButtonElement.new(circleOverlay, self.baseHud) - local x, y = unpack(self.lines[8].right) + local x, y = unpack(self.lines[self.numLines].right) x = x - onOffBtnWidth - self.wMargin/2 self.startStopRecordingBtn:setPosition(x, y - self.hMargin/16) self.startStopRecordingBtn:setCallback("onClickPrimary", self.vehicle, function (vehicle) @@ -259,7 +260,7 @@ function CpBaseHud:init(vehicle) self.OFF_COLOR, self.alignments.bottomRight) self.pauseRecordingBtn = CpHudButtonElement.new(pauseOverlay, self.baseHud) - local x, y = unpack(self.lines[8].right) + local x, y = unpack(self.lines[self.numLines].right) self.pauseRecordingBtn:setPosition(x, y) self.pauseRecordingBtn:setCallback("onClickPrimary", self.vehicle, function (vehicle) if vehicle:getIsCpCourseRecorderActive() then @@ -275,7 +276,7 @@ function CpBaseHud:init(vehicle) self.OFF_COLOR, self.alignments.bottomRight) self.clearCourseBtn = CpHudButtonElement.new(clearCourseOverlay, self.baseHud) - local x, y = unpack(self.lines[8].right) + local x, y = unpack(self.lines[self.numLines].right) x = x - 2*width - self.wMargin/2 - self.wMargin/4 self.clearCourseBtn:setPosition(x, y) self.clearCourseBtn:setCallback("onClickPrimary", self.vehicle, function (vehicle) @@ -293,7 +294,7 @@ function CpBaseHud:init(vehicle) self.alignments.bottomRight) self.goalBtn = CpHudButtonElement.new(goalOverlay, self.baseHud) - local x, y = unpack(self.lines[7].right) + local x, y = unpack(self.lines[self.numLines-1].right) self.goalBtn:setPosition(x + self.wMargin/4, y - self.hMargin/3) self.goalBtn:setCallback("onClickPrimary", vehicle, function (vehicle) self:openCourseGeneratorGui(vehicle) @@ -355,7 +356,7 @@ function CpBaseHud:addRightLineTextButton(parent, line, textSize, callbackFunc, return element end -function CpBaseHud:addLineTextButton(parent, line, textSize, setting) +function CpBaseHud:addLineTextButton(parent, line, textSize, setting, customCallback) local x, y = unpack(self.lines[line].left) local dx, dy = unpack(self.lines[line].right) local btnYOffset = self.hMargin*0.1 @@ -372,7 +373,20 @@ function CpBaseHud:addLineTextButton(parent, line, textSize, setting) class = setting, func = setting.setNextItem, } - element:setCallback(callbackLabel, callbackText) + if customCallback then + element:setCallback({ + callbackStr = "onClickPrimary", + class = setting, + func = customCallback, + }, + { + callbackStr = "onClickPrimary", + class = setting, + func = customCallback, + }) + else + element:setCallback(callbackLabel, callbackText) + end return element end @@ -473,7 +487,7 @@ function CpBaseHud:getActiveHudPage(vehicle) elseif vehicle:cpIsHudUnloaderJobSelected() then return self.combineUnloaderLayout elseif vehicle:cpIsHudStreetJobSelected() then - -- return self.fieldworkLayout + return self.streetWorkerLayout end end @@ -530,6 +544,8 @@ function CpBaseHud:updateContent(vehicle, status) self.bunkerSiloWorkerLayout:setDisabled(true) self.siloLoaderWorkerLayout:setVisible(false) self.siloLoaderWorkerLayout:setDisabled(true) + self.streetWorkerLayout:setVisible(false) + self.streetWorkerLayout:setDisabled(true) local activeLayout = self:getActiveHudPage(vehicle) if activeLayout then diff --git a/scripts/gui/hud/CpBunkerSiloWorkerHudPage.lua b/scripts/gui/hud/CpBunkerSiloWorkerHudPage.lua index f00e1face..e2db5d806 100644 --- a/scripts/gui/hud/CpBunkerSiloWorkerHudPage.lua +++ b/scripts/gui/hud/CpBunkerSiloWorkerHudPage.lua @@ -12,24 +12,24 @@ function CpBunkerSiloWorkerHudPageElement:setupElements(baseHud, vehicle, lines, --- Driving direction - self.driveDirectionBtn = baseHud:addLineTextButton(self, 4, CpBaseHud.defaultFontSize, + self.driveDirectionBtn = baseHud:addLineTextButton(self, CpBaseHud.numLines - 4, CpBaseHud.defaultFontSize, vehicle:getCpBunkerSiloWorkerJobParameters().drivingForwardsIntoSilo) --- Leveler height offset. - self.levelerHeightOffsetBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, 4, CpBaseHud.defaultFontSize, + self.levelerHeightOffsetBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, CpBaseHud.numLines - 4, CpBaseHud.defaultFontSize, vehicle:getCpSettings().levelerHeightOffset) --- Waiting at park position - self.waitAtBtn = baseHud:addLineTextButton(self, 1, CpBaseHud.defaultFontSize, + self.waitAtBtn = baseHud:addLineTextButton(self, CpBaseHud.numLines - 7, CpBaseHud.defaultFontSize, vehicle:getCpBunkerSiloWorkerJobParameters().waitAtParkPosition) --- Work width - self.workWidthBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, 2, CpBaseHud.defaultFontSize, + self.workWidthBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, CpBaseHud.numLines - 6, CpBaseHud.defaultFontSize, vehicle:getCpSettings().bunkerSiloWorkWidth) --- Bunker silo compaction percentage - local x, y = unpack(lines[3].left) - local xRight,_ = unpack(lines[3].right) + local x, y = unpack(lines[CpBaseHud.numLines - 5].left) + local xRight,_ = unpack(lines[CpBaseHud.numLines - 5].right) self.compactionPercentageBtn = CpHudTextSettingElement.new(self, x, y, xRight, CpBaseHud.defaultFontSize) local callback = { diff --git a/scripts/gui/hud/CpCombineUnloaderHudPage.lua b/scripts/gui/hud/CpCombineUnloaderHudPage.lua index 1b61ea8ac..490fb906e 100644 --- a/scripts/gui/hud/CpCombineUnloaderHudPage.lua +++ b/scripts/gui/hud/CpCombineUnloaderHudPage.lua @@ -18,19 +18,19 @@ end function CpCombineUnloaderHudPageElement:setupElements(baseHud, vehicle, lines, wMargin, hMargin) --- Tool offset x - self.combineOffsetXBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, 3, CpBaseHud.defaultFontSize, + self.combineOffsetXBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, CpBaseHud.numLines - 5, CpBaseHud.defaultFontSize, vehicle:getCpSettings().combineOffsetX) --- Tool offset z - self.combineOffsetZBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, 2, CpBaseHud.defaultFontSize, + self.combineOffsetZBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, CpBaseHud.numLines - 6, CpBaseHud.defaultFontSize, vehicle:getCpSettings().combineOffsetZ) --- Full threshold - self.fullThresholdBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, 4, CpBaseHud.defaultFontSize, + self.fullThresholdBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, CpBaseHud.numLines - 4, CpBaseHud.defaultFontSize, vehicle:getCpSettings().fullThreshold) --- Unloading combine or silo loader ? - self.unloadModeBtn = baseHud:addLineTextButton(self, 5, CpBaseHud.defaultFontSize, + self.unloadModeBtn = baseHud:addLineTextButton(self, CpBaseHud.numLines - 3, CpBaseHud.defaultFontSize, vehicle:getCpCombineUnloaderJobParameters().unloadTarget) --- Drive now button @@ -43,7 +43,7 @@ function CpCombineUnloaderHudPageElement:setupElements(baseHud, vehicle, lines, CpBaseHud.alignments.bottomRight) self.driveNowBtn = CpHudButtonElement.new(driveNowOverlay, self) - local x, y = unpack(lines[8].right) + local x, y = unpack(lines[CpBaseHud.numLines].right) y = y - hMargin/4 local driveNowBtnX = x - 2*width - wMargin/2 - wMargin/8 self.driveNowBtn:setPosition(driveNowBtnX, y) diff --git a/scripts/gui/hud/CpFieldworkHudPage.lua b/scripts/gui/hud/CpFieldworkHudPage.lua index 21f27c862..326235c1d 100644 --- a/scripts/gui/hud/CpFieldworkHudPage.lua +++ b/scripts/gui/hud/CpFieldworkHudPage.lua @@ -13,7 +13,7 @@ end function CpFieldWorkHudPageElement:setupElements(baseHud, vehicle, lines, wMargin, hMargin) --- Time remaining text - local x, y = unpack(lines[7].right) + local x, y = unpack(lines[CpBaseHud.numLines - 1].right) self.timeRemainingText = CpTextHudElement.new(self, x - 2 * baseHud.wMargin, y, CpBaseHud.defaultFontSize, RenderText.ALIGN_RIGHT) @@ -25,7 +25,7 @@ function CpFieldWorkHudPageElement:setupElements(baseHud, vehicle, lines, wMargi CpBaseHud.OFF_COLOR, CpBaseHud.alignments.bottomRight) self.courseVisibilityBtn = CpHudButtonElement.new(courseVisibilityOverlay, self) - local x, y = unpack(lines[8].right) + local x, y = unpack(lines[CpBaseHud.numLines].right) y = y - hMargin/8 local courseVisibilityBtnX = x - width - wMargin/4 self.courseVisibilityBtn:setPosition(courseVisibilityBtnX, y) @@ -46,34 +46,34 @@ function CpFieldWorkHudPageElement:setupElements(baseHud, vehicle, lines, wMargi end) --- Starting point - self.startingPointBtn = baseHud:addLeftLineTextButton(self, 5, CpBaseHud.defaultFontSize, + self.startingPointBtn = baseHud:addLeftLineTextButton(self, CpBaseHud.numLines - 3, CpBaseHud.defaultFontSize, function (vehicle) vehicle:getCpStartingPointSetting():setNextItem() end, vehicle) --- Work width - self.workWidthBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, 3, CpBaseHud.defaultFontSize, + self.workWidthBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, CpBaseHud.numLines - 5, CpBaseHud.defaultFontSize, vehicle:getCourseGeneratorSettings().workWidth) --- Tool offset x - self.toolOffsetXBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, 2, CpBaseHud.defaultFontSize, + self.toolOffsetXBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, CpBaseHud.numLines - 6, CpBaseHud.defaultFontSize, vehicle:getCpSettings().toolOffsetX) --- Lane offset - self.laneOffsetBtn = baseHud:addRightLineTextButton(self, 5, CpBaseHud.defaultFontSize, + self.laneOffsetBtn = baseHud:addRightLineTextButton(self, CpBaseHud.numLines - 3, CpBaseHud.defaultFontSize, function (vehicle) vehicle:getCpLaneOffsetSetting():setNextItem() end, vehicle) --- Course name - self.courseNameBtn = baseHud:addLeftLineTextButton(self, 4, CpBaseHud.defaultFontSize, + self.courseNameBtn = baseHud:addLeftLineTextButton(self, CpBaseHud.numLines - 4, CpBaseHud.defaultFontSize, function(vehicle) baseHud:openCourseGeneratorGui(vehicle) end, vehicle) --- Waypoint progress - self.waypointProgressBtn = baseHud:addRightLineTextButton(self, 4, CpBaseHud.defaultFontSize, + self.waypointProgressBtn = baseHud:addRightLineTextButton(self, CpBaseHud.numLines - 4, CpBaseHud.defaultFontSize, function(vehicle) baseHud:openCourseManagerGui(vehicle) end, vehicle) diff --git a/scripts/gui/hud/CpSiloLoaderWorkerHudPage.lua b/scripts/gui/hud/CpSiloLoaderWorkerHudPage.lua index 595605375..4db47a993 100644 --- a/scripts/gui/hud/CpSiloLoaderWorkerHudPage.lua +++ b/scripts/gui/hud/CpSiloLoaderWorkerHudPage.lua @@ -17,20 +17,20 @@ end function CpSiloLoaderWorkerHudPageElement:setupElements(baseHud, vehicle, lines, wMargin, hMargin) --- Work width - self.workWidthBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, 3, CpBaseHud.defaultFontSize, + self.workWidthBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, CpBaseHud.numLines - 5, CpBaseHud.defaultFontSize, vehicle:getCpSettings().bunkerSiloWorkWidth) --- Displays the fill level of current worked on heap. - local x, y = unpack(lines[4].left) + local x, y = unpack(lines[CpBaseHud.numLines - 4].left) self.fillLevelProgressLabel = CpTextHudElement.new(self , x , y, CpBaseHud.defaultFontSize) self.fillLevelProgressLabel:setTextDetails(g_i18n:getText("CP_siloLoader_fillLevelProgress")) --- Displays the fill level of current worked on heap. - local x, y = unpack(lines[4].right) + local x, y = unpack(lines[CpBaseHud.numLines - 4].right) self.fillLevelProgressText = CpTextHudElement.new(self, x, y, CpBaseHud.defaultFontSize, RenderText.ALIGN_RIGHT) --- Shovel loading height offset. - self.loadingShovelHeightOffsetBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, 2, CpBaseHud.defaultFontSize, + self.loadingShovelHeightOffsetBtn = baseHud:addLineTextButtonWithIncrementalButtons(self, CpBaseHud.numLines - 6, CpBaseHud.defaultFontSize, vehicle:getCpSettings().loadingShovelHeightOffset) CpGuiUtil.addCopyAndPasteButtons(self, baseHud, diff --git a/scripts/gui/hud/CpStreetWorkerHudPage.lua b/scripts/gui/hud/CpStreetWorkerHudPage.lua new file mode 100644 index 000000000..2e7b7af5e --- /dev/null +++ b/scripts/gui/hud/CpStreetWorkerHudPage.lua @@ -0,0 +1,187 @@ +--- Fieldwork Hud page +---@class CpStreetWorkerHudPageElement : CpHudElement +CpStreetWorkerHudPageElement = {} +local CpStreetWorkerHudPageElement_mt = Class(CpStreetWorkerHudPageElement, CpHudPageElement) + +function CpStreetWorkerHudPageElement.new(overlay, parentHudElement, customMt) + local self = CpHudPageElement.new(overlay, parentHudElement, customMt or CpStreetWorkerHudPageElement_mt) + return self +end + +function CpStreetWorkerHudPageElement:setupElements(baseHud, vehicle, lines, wMargin, hMargin) + + self.loadUnloadTargetModeBtn = baseHud:addLeftLineTextButton(self, CpBaseHud.numLines - 3, CpBaseHud.defaultFontSize, + function (vehicle) + vehicle:getCpStreetWorkerJobParameters().loadUnloadTargetMode:setNextItem() + end, vehicle) + + self.unloadTargetPointBtn = baseHud:addLineTextButton(self, CpBaseHud.numLines - 4, + CpBaseHud.defaultFontSize, vehicle:getCpStreetWorkerJobParameters().unloadTargetPoint, + function() + TargetPointSelectionDialog.show( + {vehicle:getCpStreetWorkerJobParameters().unloadTargetPoint, + vehicle:getCpStreetWorkerJobParameters().loadTargetPoint}) + end) + + self.loadTargetPointBtn = baseHud:addLineTextButton(self, CpBaseHud.numLines - 5, + CpBaseHud.defaultFontSize, vehicle:getCpStreetWorkerJobParameters().loadTargetPoint, + function() + TargetPointSelectionDialog.show( + {vehicle:getCpStreetWorkerJobParameters().unloadTargetPoint, + vehicle:getCpStreetWorkerJobParameters().loadTargetPoint}) + end) + + self.fillTypeButtons = { + baseHud:addLineTextButton(self, CpBaseHud.numLines - 6, + CpBaseHud.defaultFontSize, vehicle:getCpStreetWorkerJobParameters().loadTargetPoint, + function() + FilltypeSelectionDialog.show( + vehicle:getCpStreetWorkerJobParameters().fillTypeSelection1) + end), + baseHud:addLineTextButton(self, CpBaseHud.numLines - 7, + CpBaseHud.defaultFontSize, vehicle:getCpStreetWorkerJobParameters().loadTargetPoint, + function() + FilltypeSelectionDialog.show( + vehicle:getCpStreetWorkerJobParameters().fillTypeSelection2) + end), + baseHud:addLineTextButton(self, CpBaseHud.numLines - 8, + CpBaseHud.defaultFontSize, vehicle:getCpStreetWorkerJobParameters().loadTargetPoint, + function() + FilltypeSelectionDialog.show( + vehicle:getCpStreetWorkerJobParameters().fillTypeSelection3) + end)} + + + -- local x, y = unpack(lines[5].left) + -- self.loadMultipleFillTypesSetting = CpTextHudElement.new(self, + -- x, y, baseHud.defaultFontSize) + -- x, y = unpack(lines[4].right) + -- local max = CpTextHudElement.new(self, + -- x , y, baseHud.defaultFontSize, RenderText.ALIGN_RIGHT) + -- max:setDisabled(true) + -- max:setTextDetails("max") + -- x = x - max:getTextWidth("100%") - wMargin/2 + -- local min = CpTextHudElement.new(self, + -- x, y, baseHud.defaultFontSize, RenderText.ALIGN_RIGHT) + -- min:setDisabled(true) + -- min:setTextDetails("min") + -- x = x - min:getTextWidth("100%") - wMargin/2 + -- local counter = CpTextHudElement.new(self, + -- x, y, baseHud.defaultFontSize, RenderText.ALIGN_RIGHT) + -- counter:setDisabled(true) + -- counter:setTextDetails("count") + + -- self.fillTypeSettings = { + -- self:addFillTypeSelection(3, baseHud, vehicle, + -- lines, wMargin, hMargin), + -- self:addFillTypeSelection(2, baseHud, vehicle, + -- lines, wMargin, hMargin), + -- self:addFillTypeSelection(1, baseHud, vehicle, + -- lines, wMargin, hMargin) + -- } + + CpGuiUtil.addCopyAndPasteButtons(self, baseHud, + vehicle, lines, wMargin, hMargin, 1) + + self.copyButton:setCallback("onClickPrimary", vehicle, function (vehicle) + if not CpBaseHud.copyPasteCache.hasVehicle and vehicle.getCpStreetWorkerJob then + CpBaseHud.copyPasteCache.streetWorkerVehicle = vehicle + CpBaseHud.copyPasteCache.hasVehicle = true + end + end) + + + self.pasteButton:setCallback("onClickPrimary", vehicle, function (vehicle) + if CpBaseHud.copyPasteCache.hasVehicle and not vehicle:getIsCpActive() then + if CpBaseHud.copyPasteCache.streetWorkerVehicle then + vehicle:applyCpStreetWorkerJobParameters(CpBaseHud.copyPasteCache.streetWorkerVehicle:getCpStreetWorkerJob()) + end + end + end) + + self.clearCacheBtn:setCallback("onClickPrimary", vehicle, function (vehicle) + CpBaseHud.copyPasteCache.hasVehicle = false + CpBaseHud.copyPasteCache.streetWorkerVehicle = nil + end) +end + +function CpStreetWorkerHudPageElement:addFillTypeSelection(line, baseHud, vehicle, lines, wMargin, hMargin) + --- Fill type + local x, y = unpack(lines[line].left) + local fillType = CpTextHudElement.new(self, + x , y, baseHud.defaultFontSize) + fillType:setDisabled(true) + x, y = unpack(lines[line].right) + local max = CpTextHudElement.new(self, + x , y, baseHud.defaultFontSize, RenderText.ALIGN_RIGHT) + max:setDisabled(true) + x = x - max:getTextWidth("100%") - wMargin/2 + local min = CpTextHudElement.new(self, + x , y, baseHud.defaultFontSize, RenderText.ALIGN_RIGHT) + min:setDisabled(true) + x = x - min:getTextWidth("100%") - wMargin/2 + local counter = CpTextHudElement.new(self, + x , y, baseHud.defaultFontSize, RenderText.ALIGN_RIGHT) + counter:setDisabled(true) + return { + fillType = fillType, + min = min, + max = max, + counter = counter + } +end + +function CpStreetWorkerHudPageElement:update(dt) + CpStreetWorkerHudPageElement:superClass().update(self, dt) + +end + +---@param vehicle table +---@param status CpStatus +function CpStreetWorkerHudPageElement:updateContent(vehicle, status) + + local jobParameters = vehicle:getCpStreetWorkerJobParameters() + self.loadUnloadTargetModeBtn:setTextDetails(jobParameters.loadUnloadTargetMode:getString()) + self.unloadTargetPointBtn:setTextDetails( + jobParameters.unloadTargetPoint:getTitle(), + jobParameters.unloadTargetPoint:getString()) + self.unloadTargetPointBtn:setDisabled(jobParameters.unloadTargetPoint:getIsDisabled()) + self.loadTargetPointBtn:setTextDetails( + jobParameters.loadTargetPoint:getTitle(), + jobParameters.loadTargetPoint:getString()) + self.loadTargetPointBtn:setVisible(not jobParameters.loadTargetPoint:getIsDisabled()) + local fillTypeSettings = jobParameters:getFillTypeSelectionSettings() + for ix, fillTypeBtn in ipairs(self.fillTypeButtons) do + fillTypeBtn:setDisabled(fillTypeSettings[ix]:getIsDisabled()) + fillTypeBtn:setTextDetails(fillTypeSettings[ix]:getTitle(), fillTypeSettings[ix]:getString()) + fillTypeBtn:setVisible(not jobParameters.loadTargetPoint:getIsDisabled()) + end + self:updateCopyButtons(vehicle) +end + +function CpStreetWorkerHudPageElement:updateCopyButtons(vehicle) + if CpBaseHud.copyPasteCache.hasVehicle then + self.clearCacheBtn:setVisible(true) + self.pasteButton:setVisible(true) + self.copyButton:setVisible(false) + local copyCacheVehicle = CpBaseHud.copyPasteCache.streetWorkerVehicle + local text = CpUtil.getName(copyCacheVehicle) + self.copyCacheText:setTextDetails(text) + self.copyCacheText:setTextColorChannels(unpack(CpBaseHud.OFF_COLOR)) + self.pasteButton:setColor(unpack(CpBaseHud.OFF_COLOR)) + self.pasteButton:setDisabled(true) + if copyCacheVehicle == vehicle or vehicle:getIsCpActive() then + --- Paste disabled + return + end + self.copyCacheText:setTextColorChannels(unpack(CpBaseHud.WHITE_COLOR)) + self.pasteButton:setColor(unpack(CpBaseHud.ON_COLOR)) + self.pasteButton:setDisabled(false) + + else + self.copyCacheText:setTextDetails("") + self.clearCacheBtn:setVisible(false) + self.pasteButton:setVisible(false) + self.copyButton:setVisible(true) + end +end \ No newline at end of file diff --git a/scripts/gui/pages/CpConstructionFrame.lua b/scripts/gui/pages/CpConstructionFrame.lua index 56e9a246d..c34bf4937 100644 --- a/scripts/gui/pages/CpConstructionFrame.lua +++ b/scripts/gui/pages/CpConstructionFrame.lua @@ -108,7 +108,7 @@ function CpConstructionFrame:onFrameOpen() local texts = {} for _, tab in pairs(self.brushCategory) do - table.insert(texts, tab.name) + table.insert(texts, g_i18n:convertText(tab.name)) end self.subCategorySelector:setTexts(texts) for ix, clone in ipairs(self.subCategoryDotBox.elements) do diff --git a/scripts/gui/pages/CpCourseGeneratorFrame.lua b/scripts/gui/pages/CpCourseGeneratorFrame.lua index c76814fad..723702b4a 100644 --- a/scripts/gui/pages/CpCourseGeneratorFrame.lua +++ b/scripts/gui/pages/CpCourseGeneratorFrame.lua @@ -662,6 +662,7 @@ function CpCourseGeneratorFrame:onDrawPostIngameMap(element, ingameMap) if self.currentJob and self.currentJob.draw then self.currentJob:draw(ingameMap, self.mode == self.MODE_OVERVIEW) end + g_graph:drawMap(ingameMap) --- Draws the current progress, while creating a custom field. -- if pageAI.mode == CpInGameMenuAIFrameExtended.MODE_DRAW_FIELD_BORDER and next(CpInGameMenuAIFrameExtended.curDrawPositions) then @@ -775,8 +776,26 @@ function CpCourseGeneratorFrame:onClickMultiTextOptionParameter(index, element) self:validateParameters() end -function CpCourseGeneratorFrame:onClickMultiTextOptionCenterParameter() - +function CpCourseGeneratorFrame:onClickMultiTextOptionCenterParameter(element) + if self.currentJob ~= nil then + local param = element.aiParameter + if param then + if param:isa(CpAIParameterTargetPoint) then + local settings = self.currentJob:getCpJobParameters():getTargetPointSettings() + TargetPointSelectionDialog.show(settings, function() + self.currentJob:onParameterValueChanged(param) + self:updateParameterValueTexts() + self:validateParameters() + end) + elseif param:isa(CpAIParameterFillTypeSetting) then + FilltypeSelectionDialog.show(param, function() + self.currentJob:onParameterValueChanged(param) + self:updateParameterValueTexts() + self:validateParameters() + end) + end + end + end end function CpCourseGeneratorFrame:executePickingCallback(...) @@ -908,11 +927,13 @@ function CpCourseGeneratorFrame:onFieldBoundaryDetectionFinished(isValid, errorT end function CpCourseGeneratorFrame:updateWarnings() - for _, element in ipairs(self.currentJobElements) do - local parameter = element.aiParameter - local invalidElement = element:getDescendantByName("invalid") - if invalidElement ~= nil then - invalidElement:setVisible(not parameter:getIsValid() and parameter:getCanBeChanged()) + for _, group in ipairs(self.currentJobElements) do + for _, element in ipairs(group.elements) do + local parameter = element.aiParameter + local invalidElement = element:getDescendantByName("invalid") + if invalidElement ~= nil then + invalidElement:setVisible(not parameter:getIsValid() and parameter:getCanBeChanged()) + end end end end @@ -922,39 +943,54 @@ function CpCourseGeneratorFrame:updateParameterValueTexts() g_currentMission:removeMapHotspot(self.fieldSiloAiTargetMapHotspot) g_currentMission:removeMapHotspot(self.unloadAiTargetMapHotspot) g_currentMission:removeMapHotspot(self.loadAiTargetMapHotspot) - for _, element in ipairs(self.currentJobElements) do - local parameter = element.aiParameter - local invalidElement = element:getDescendantByName("invalid") - if invalidElement ~= nil then - invalidElement:setVisible(not parameter:getIsValid() and parameter:getCanBeChanged()) - end - element:setDisabled(not parameter:getCanBeChanged()) - local parameterType = parameter:getType() - if parameterType == AIParameterType.TEXT then - local title = element:getDescendantByName("title") - - title:setText(parameter:getString()) - elseif parameter.is_a and parameter:is_a(CpAIParameterPosition) then - element:setText(parameter:getString()) - if parameter:getPositionType() == CpAIParameterPositionAngle.POSITION_TYPES.DRIVE_TO then - if parameter:applyToMapHotspot(self.driveToAiTargetMapHotspot) then - g_currentMission:addMapHotspot(self.driveToAiTargetMapHotspot) - end - elseif parameter:getPositionType() == CpAIParameterPositionAngle.POSITION_TYPES.FIELD_OR_SILO then - if parameter:applyToMapHotspot(self.fieldSiloAiTargetMapHotspot) then - g_currentMission:addMapHotspot(self.fieldSiloAiTargetMapHotspot) - end - elseif parameter:getPositionType() == CpAIParameterPositionAngle.POSITION_TYPES.UNLOAD then - if parameter:applyToMapHotspot(self.unloadAiTargetMapHotspot) then - g_currentMission:addMapHotspot(self.unloadAiTargetMapHotspot) + for _, groupElement in ipairs(self.currentJobElements) do + groupElement.title:setVisible(groupElement.group:getIsVisible()) + for _, element in ipairs(groupElement.elements) do + local parameter = element.aiParameter + local invalidElement = element:getDescendantByName("invalid") + if invalidElement ~= nil then + invalidElement:setVisible(not parameter:getIsValid() and parameter:getCanBeChanged()) + end + local icon = element:getDescendantByName("icon") + if icon ~= nil and parameter.getCustomIconFilename then + local filename = parameter:getCustomIconFilename() + if filename then + icon:setImageFilename(parameter:getCustomIconFilename()) + icon:setImageUVs(0,0,0,0,1,1,0,1,1) end - elseif parameter:getPositionType() == CpAIParameterPositionAngle.POSITION_TYPES.LOAD then - if parameter:applyToMapHotspot(self.loadAiTargetMapHotspot) then - g_currentMission:addMapHotspot(self.loadAiTargetMapHotspot) + icon:setVisible(filename ~= nil) + end + element:setDisabled(groupElement.group:getIsDisabled() or not parameter:getCanBeChanged()) + element:setVisible(groupElement.group:getIsVisible() and (parameter.getIsVisible == nil or parameter:getIsVisible())) + local parameterType = parameter:getType() + if parameterType == AIParameterType.TEXT then + local title = element:getDescendantByName("title") + + title:setText(parameter:getString()) + elseif parameterType == AIParameterType.TEXT_BUTTON then + element:setText(parameter:getString()) + elseif parameter.is_a and parameter:is_a(CpAIParameterPosition) then + element:setText(parameter:getString()) + if parameter:getPositionType() == CpAIParameterPositionAngle.POSITION_TYPES.DRIVE_TO then + if parameter:applyToMapHotspot(self.driveToAiTargetMapHotspot) then + g_currentMission:addMapHotspot(self.driveToAiTargetMapHotspot) + end + elseif parameter:getPositionType() == CpAIParameterPositionAngle.POSITION_TYPES.FIELD_OR_SILO then + if parameter:applyToMapHotspot(self.fieldSiloAiTargetMapHotspot) then + g_currentMission:addMapHotspot(self.fieldSiloAiTargetMapHotspot) + end + elseif parameter:getPositionType() == CpAIParameterPositionAngle.POSITION_TYPES.UNLOAD then + if parameter:applyToMapHotspot(self.unloadAiTargetMapHotspot) then + g_currentMission:addMapHotspot(self.unloadAiTargetMapHotspot) + end + elseif parameter:getPositionType() == CpAIParameterPositionAngle.POSITION_TYPES.LOAD then + if parameter:applyToMapHotspot(self.loadAiTargetMapHotspot) then + g_currentMission:addMapHotspot(self.loadAiTargetMapHotspot) + end end + elseif element.updateTitle then + element:updateTitle() end - elseif element.updateTitle then - element:updateTitle() end end end @@ -1631,9 +1667,12 @@ function CpCourseGeneratorFrame:setActiveJobTypeSelection(jobTypeIndex) self.currentJobElements = {} for _, group in ipairs(self.currentJob:getGroupedParameters()) do local titleElement = self.createTitleTemplate:clone(self.jobMenuLayout) - titleElement:setText(group:getTitle()) - + local groupElements = { + group = group, + title = titleElement, + elements = {} + } for _, item in ipairs(group:getParameters()) do local element = nil local parameterType = item:getType() @@ -1649,20 +1688,31 @@ function CpCourseGeneratorFrame:setActiveJobTypeSelection(jobTypeIndex) parameterType == AIParameterType.FILLTYPE then element = self.createMultiOptionTemplate:clone(self.jobMenuLayout) - element:setDataSource(item) + elseif parameterType == AIParameterType.TEXT_BUTTON then + element = self.createButtonTemplate:clone(self.jobMenuLayout) + element:setText(item:getString()) end if element then FocusManager:loadElementFromCustomValues(element) - element.aiParameter = item if element.updateTitle then element:updateTitle() end element:setDisabled(not item:getCanBeChanged()) - table.insert(self.currentJobElements, element) + element:setVisible(item.getIsVisible == nil or item:getIsVisible()) + local icon = element:getDescendantByName("icon") + if icon ~= nil and item.getCustomIconFilename then + local filename = item:getCustomIconFilename() + if filename then + icon:setImageFilename(filename) + end + icon:setVisible(filename ~= nil) + end + table.insert(groupElements.elements, element) end end + table.insert(self.currentJobElements, groupElements) end self:validateParameters() self:updateParameterValueTexts() diff --git a/scripts/gui/plots/CoursePlot.lua b/scripts/gui/plots/CoursePlot.lua index e111fae50..6cdff40db 100644 --- a/scripts/gui/plots/CoursePlot.lua +++ b/scripts/gui/plots/CoursePlot.lua @@ -39,6 +39,7 @@ function CoursePlot:init() self.stopPosition = {} self.drawArrows = true self.isVisible = false + self.invertArrowDirection = false end function CoursePlot:delete() @@ -60,8 +61,9 @@ function CoursePlot:setVisible( isVisible ) self.isVisible = isVisible end -function CoursePlot:setDrawingArrows(draw) +function CoursePlot:setDrawingArrows(draw, invert) self.drawArrows = draw + self.invertArrowDirection = invert end function CoursePlot:setWaypoints( waypoints ) @@ -182,6 +184,9 @@ function CoursePlot:drawArrow(map, x, z, rotation, r, g, b, a, isHudMap) local arrowHeight = self.arrowThickness * map.uiScale * zoom * g_screenAspectRatio local ax, ay, _ = CpGuiUtil.worldToScreen(map, x, z, isHudMap) setOverlayColor( self.arrowOverlayId, r, g, b, a or 0.8) + if self.invertArrowDirection then + rotation = rotation + math.pi + end setOverlayRotation(self.arrowOverlayId, rotation, arrowWidth/2, arrowHeight/2 ) renderOverlay( self.arrowOverlayId, ax - arrowWidth/2, @@ -211,7 +216,7 @@ function CoursePlot:drawPoints(map, points, isHudMap) end if wp and np then r, g, b = MathUtil.vector3ArrayLerp(self.lightColor, self.darkColor, wp.progress or 1) - self:drawLineBetween(map, wp.x, wp.z, np.x, np.z, + self:drawLineBetween(map, wp.x or wp._x, wp.z or wp._z, np.x or np._x, np.z or np._z, isHudMap, lineThickness, r, g, b, 0.8, wp.attributes and wp.attributes.rowStart, np.attributes and np.attributes.rowEnd) diff --git a/scripts/gui/plots/GraphPlot.lua b/scripts/gui/plots/GraphPlot.lua new file mode 100644 index 000000000..a28f0b464 --- /dev/null +++ b/scripts/gui/plots/GraphPlot.lua @@ -0,0 +1,24 @@ +---@class GraphPlot : CoursePlot +GraphPlot = CpObject(CoursePlot) +function GraphPlot:init(graph) + CoursePlot.init(self) + self:setDrawingArrows(true) + -- use a thicker line + self.isVisible = true + ---@type Graph + self.graph = graph +end + +--- Draws custom fields. +---@param map table +function GraphPlot:draw(map) + if not self.isVisible then return end + local segments = self.graph:getSegments() + for _, segment in pairs(segments) do + local points = segment:getAllChildNodes() + self.darkColor = segment:getDirectionColor() + self:setDrawingArrows(not segment:isDual(), segment:isReverse()) + self:drawPoints(map, points, false) + end +end + diff --git a/scripts/pathfinder/GraphPathfinder.lua b/scripts/pathfinder/GraphPathfinder.lua new file mode 100644 index 000000000..24aaaed63 --- /dev/null +++ b/scripts/pathfinder/GraphPathfinder.lua @@ -0,0 +1,394 @@ +--- A pathfinder based on A* to find the shortest path in a directed graph. +--- The graph is defined by its edges only, there are no nodes needed. +--- +--- Edges are represented by polylines, and they can unidirectional or bidirectional. +--- Unidirectional edges can only be entered at one end (at the first vertex of the +--- polyline) and exited at the other (last vertex of the polyline) +--- +--- Bidirectional edges can be entered at either end, and exited at the other. +--- +--- Edges don't have to be connected, as long as the entry of another edge is close +--- enough to the exit of another, the entry is a valid successor node (of the exit, +--- which is the predecessor) + +---@class GraphPathfinder : HybridAStar +GraphPathfinder = CpObject(HybridAStar) + +--- An edge of a directed graph +---@class GraphPathfinder.GraphEdge : Polyline +GraphPathfinder.GraphEdge = CpObject(Polyline) + +GraphPathfinder.GraphEdge.UNIDIRECTIONAL = 1 +GraphPathfinder.GraphEdge.BIDIRECTIONAL = 2 + +---@param direction table GraphEdge.UNIDIRECTIONAL or GraphEdge.BIDIRECTIONAL +---@param vertices table[] array of tables with x, y (Vector, Vertex, State3D or just plain {x, y} +function GraphPathfinder.GraphEdge:init(direction, vertices) + Polyline.init(self, vertices) + self.direction = direction +end + +-- allow to inherit clone +function GraphPathfinder.GraphEdge:_getNewInstance() + return GraphPathfinder.GraphEdge() +end + +function GraphPathfinder.GraphEdge:clone() + local clone = Polyline.clone(self) + clone.direction = self.direction + return clone +end + +function GraphPathfinder.GraphEdge:getDirection() + return self.direction +end + +---@return boolean is this a bidirectional edge? +function GraphPathfinder.GraphEdge:isBidirectional() + return self.direction == GraphPathfinder.GraphEdge.BIDIRECTIONAL +end + +---@return Vertex[] array of vertices that can be used to enter this edge (one for +--- unidirectional, two for bidirectional edges) +function GraphPathfinder.GraphEdge:getEntries() + if self:isBidirectional() then + return { self[1], self[#self] } + else + return { self[1] } + end +end + +---@param entry Vector +---@return Vector the exit when entered through the given entry +function GraphPathfinder.GraphEdge:getExit(entry) + if entry == self[1] then + return self[#self] + else + return self[1] + end +end + +function GraphPathfinder.GraphEdge:rollUpIterator(entry) + local from, to, step + if entry == self[1] then + -- unidirectional, or bidirectional, travelling from the start to end, roll up backwards + from, to, step = #self + 1, 1, -1 + else + from, to, step = 0, #self, 1 + end + local i = from + return function() + i = i + step + if i == to + step then + return nil, nil + else + return i, self[i] + end + end +end + +function GraphPathfinder.GraphEdge.getDirectionFromGameDirection(gameDirection) + if gameDirection == GraphSegmentDirection.FORWARD or gameDirection == GraphSegmentDirection.REVERSE then + return GraphPathfinder.GraphEdge.UNIDIRECTIONAL + else + return GraphPathfinder.GraphEdge.BIDIRECTIONAL + end +end + +GraphPathfinder.HelperGraphEdge = CpObject(GraphPathfinder.GraphEdge) +--- Helper edges are to entry/exit the graph from the goal/start. We don't want these to be part of the path, so we +--- the roll up iterator returns nothing, automatically skip the vertices of these edges +function GraphPathfinder.HelperGraphEdge:rollUpIterator(entry) + return function() + end +end + +--- A pathfinder node, specialized for the GraphPathfinder +---@class GraphPathfinder.Node : State3D +GraphPathfinder.Node = CpObject(State3D) + +---@param edge GraphPathfinder.GraphEdge the edge leading to this node: when rolling up the path, we need to add all +--- vertices of the edge +---@param entry Vector the entry point to this edge (when bidirectional, we may be travelling the edge from the end to +--- the start. +function GraphPathfinder.Node:init(x, y, g, pred, d, edge, entry) + State3D.init(self, x, y, 0, g, pred, Gear.Forward, Steer.Straight, 0, d) + self.edge = edge + self.entry = entry +end + +--- Create a graph pathfinder. +--- +--- Use start(start, goal) as described at PathfinderInterface:start() to run the pathfinder. +--- The entry at the graph will be at the vertex closest to the start location, the exit at the vertex closest +--- to the goal location. (The graph's edges are polylines, consisting of vertices). There is no limitation for the +--- distance between the entry/exit vertices and the start/goal locations. +--- +--- The resulting path will only contain vertices of the edges that are traversed from the entry to the exit, it +--- will not contain the start or the goal. The caller is responsible for creating the sections from the start to the +--- entry (first point of the path) and from the exit (last point of the path) to the goal. +--- +---@param yieldAfter number coroutine yield after so many iterations (number of iterations in one update loop) +---@param maxIterations number maximum iterations before failing +---@param range number when an edge's exit is closer than range to another edge's entry, the +--- two edges are considered as connected (and thus can traverse from one to the other) +---@param graph GraphPathfinder.GraphEdge[] Array of edges, the graph as described in the file header +function GraphPathfinder:init(yieldAfter, maxIterations, range, graph) + HybridAStar.init(self, { }, yieldAfter, maxIterations) + self.logger = Logger('GraphPathfinder', Logger.level.debug, CpDebug.DBG_PATHFINDER) + self.range = range + self.transitionRange = range + self.originalGraph = graph + self.deltaPosGoal = self.range + self.deltaThetaDeg = 181 + self.deltaThetaGoal = math.rad(self.deltaThetaDeg) + self.maxDeltaTheta = self.deltaThetaGoal + self.originalDeltaThetaGoal = self.deltaThetaGoal + self.analyticSolverEnabled = false + self.ignoreValidityAtStart = false +end + +--- for backwards compatibility with the old pathfinder +function GraphPathfinder:debug(...) + self.logger:debug(...) +end + +function GraphPathfinder:getMotionPrimitives(turnRadius, allowReverse) + return GraphPathfinder.GraphMotionPrimitives(self.range, self.graph) +end + +--- Override path roll up since here, the path also includes all edges of the graph, not just the pathfinder nodes +---@param lastNode GraphPathfinder.Node +function GraphPathfinder:rollUpPath(lastNode, goal) + local edges = {} + local currentNode = lastNode + self:debug('Goal node at %.2f/%.2f, cost %.1f (%.1f - %.1f)', goal.x, goal.y, lastNode.cost, + self.nodes.lowestCost, self.nodes.highestCost) + while currentNode.pred and currentNode ~= currentNode.pred do + -- add the edge leading to the node + local edge = Polyline() + for _, node in currentNode.edge:rollUpIterator(currentNode.entry) do + if node ~= edge[1] then + -- don't insert the same node twice (we'll have the same node twice when we split edges) + edge:prepend(node) + end + end + if #edge > 0 then + edge:calculateProperties() + table.insert(edges, 1, edge) + end + currentNode = currentNode.pred + end + local path = self:addTransitions(edges) + self:debug('Nodes %d, iterations %d, yields %d, deltaTheta %.1f', #path, self.iterations, self.yields, + math.deg(self.deltaThetaGoal)) + return path +end + +function GraphPathfinder:start(start, goal, turnRadius, ...) + -- at each run, make a copy of the graph as we'll modify it + self.graph = {} + for _, e in ipairs(self.originalGraph) do + if #e > 1 then + table.insert(self.graph, e:clone()) + elseif #e == 1 then + -- edges with one vertex put the pathfinder in an endless loop, so we skip them + self.logger:error('Skipping edge starting at x = %.1f, z = %.1f with a single vertex', e[1].x, -e[1].y) + else + -- this should not happen, an edge without vertices, but just in case, we skip it + self.logger:error('Skipping edge with no vertices.') + end + end + local graphEntry, graphExit = self:createGraphEntryAndExit(start, goal) + local distance = (graphExit - graphEntry):length() + if distance <= self.range then + -- if the distance between the entry and exit is less than the range, we can just return the entry as the exit + self.logger:error('Graph entry and exit are closer than %.1f meters (%.1f), no point in running the pathfinder.', + self.range, distance) + self.goalNodeInvalid = true + return self:finishRun(true, nil) + end + return HybridAStar.start(self, start, goal, turnRadius, ...) +end + +--- Create the entry and exit edges for the graph. +--- +--- The problem we are trying to solve here, is that the start and goal can be in any distance from any edge of the +--- graph, like when the vehicle sits in the middle of a field, surrounded by streets. Any street could be used as +--- an entry, and the closest street may not be the shortest path to the goal. A special case of this is when +--- the street is a two lane, two way street and our goal is to the left, but the closest edge is the right lane, +--- leading away from the goal, forcing us making an unnecessary detour. +--- +--- Therefore, it isn't enough to find the closest vertex of the graph, we need a list of the closest vertices and +--- as long as their distance is not bigger than the distance to the closest one + the range, we can use them as +--- entries/exits. +--- +--- To make sure that the algorithm actually uses these entry/exit points, we add helper edges to the graph, leading +--- from the start to the entries and from the exits to the goal. +--- +--- When the closest vertex, isn't the first or last point of the edge, we simply split that edge at that vertex so +--- the parts can be used as entries/exits. +--- +---@param start State3D the start location for the pathfinder +---@param goal State3D the goal location for the pathfinder +---@return State3D the entry vertex of the graph, closest to start +---@return State3D the exit vertex of the graph, closest to goal +function GraphPathfinder:createGraphEntryAndExit(start, goal) + + local function splitEdgeWhenNeeded(edge, closestVertex) + -- if the vertex is the first or last vertex of the edge, we can use it directly as the entry/exit, + -- otherwise, we split the edge at the vertex so we can use it as an entry/exit point. + if closestVertex.ix ~= 1 and closestVertex.ix ~= #edge then + self.logger:debug('Graph entry/exit found and split at vertex %d, x: %.1f y: %.1f', + closestVertex.ix, closestVertex.x, closestVertex.y) + local newEdge = GraphPathfinder.GraphEdge(edge:getDirection()) + for j = closestVertex.ix, #edge do + newEdge:append(edge[j]) + end + newEdge:calculateProperties() + table.insert(self.graph, newEdge) + edge:cutEndAtIx(closestVertex.ix) + return newEdge + end + end + + -- find the edges closest to node. If the closest vertex isn't the first or last vertex of the edge, we split the + -- edge at that vertex so we can use it as an entry/exit point. + local function findClosestEdges(node, isEntry) + local closestEdges = {} + + local function addToClosestEdge(edge, vertex, d) + -- only add the edge if the vertex can be used as an entry/exit point + if edge:isBidirectional() or + (isEntry and vertex.ix == 1) or + (not isEntry and vertex.ix == #edge) then + table.insert(closestEdges, { d = d, edge = edge, vertex = vertex }) + end + end + + -- we'll be adding items to self.graph from within the loop, but that should be ok, because the # is evaluated + -- before the loop starts + for i = 1, #self.graph do + local edge = self.graph[i] + local vertex, d = edge:findClosestVertexToPoint(node) + local newEdge = splitEdgeWhenNeeded(edge, vertex) + if newEdge then + addToClosestEdge(newEdge, newEdge[1], d) + end + addToClosestEdge(edge, vertex, d) + end + table.sort(closestEdges, function(a, b) + return a.d < b.d + end) + return closestEdges + end + + local entryEdges, exitEdges = findClosestEdges(start, true), findClosestEdges(goal, false) + -- only use the edge if it is close enough to the closest + local maxDistance = entryEdges[1].d + self.range + for i = 1, math.min(#entryEdges, 2) do + if entryEdges[i].d <= maxDistance then + table.insert(self.graph, GraphPathfinder.HelperGraphEdge(GraphPathfinder.UNIDIRECTIONAL, { start, entryEdges[i].vertex })) + end + end + maxDistance = exitEdges[1].d + self.range + for i = 1, math.min(#exitEdges, 2) do + if exitEdges[i].d <= maxDistance then + table.insert(self.graph, GraphPathfinder.HelperGraphEdge(GraphPathfinder.UNIDIRECTIONAL, { exitEdges[i].vertex, goal })) + end + end + return State3D(entryEdges[1].vertex.x, entryEdges[1].vertex.y, 0, 0), + State3D(exitEdges[1].vertex.x, exitEdges[1].vertex.y, 0, 0) +end + +--- Add the transitions between the edges in the graph. These are the road crossings where the path +--- between two edges is missing: they are either too far apart or too close and not connected with a proper +--- arc of the vehicle's turning radius. +---@param edges GraphPathfinder.GraphEdge[] +function GraphPathfinder:addTransitions(edges) + local path = Polyline() + for i = 1, #edges - 1 do + local lastVertex = edges[i][#edges[i]] + local exitEdge = lastVertex:getEntryEdge() + local firstVertex = edges[i + 1][1] + local entryEdge = firstVertex:getExitEdge() + CourseGenerator.LineSegment.connect(exitEdge, entryEdge, 1, true) + local corner = entryEdge:getBase() + if (corner - lastVertex):length() < self.transitionRange and + (corner - firstVertex):length() < self.transitionRange then + edges[i + 1][1] = Vertex.fromVector(entryEdge:getBase()) + table.remove(edges[i]) + end + path:appendMany(edges[i]) + end + path:appendMany(edges[#edges]) -- append the last edge, this will be the exit edge + path:calculateProperties() + path:splitEdges(5) + path:ensureMinimumRadius(self.turnRadius, false, 0.5) + return path +end + +--- Motion primitives to use with the graph pathfinder, providing the entries +--- to the next edges. +---@class GraphPathfinder.GraphMotionPrimitives : HybridAStar.MotionPrimitives +GraphPathfinder.GraphMotionPrimitives = CpObject(HybridAStar.MotionPrimitives) + +---@param range number when an edge's exit is closer than range to another edge's entry, the +--- two edges are considered as connected (and thus can traverse from one to the other) +---@param graph Vector[] the graph as described in the file header +function GraphPathfinder.GraphMotionPrimitives:init(range, graph) + self.logger = Logger('GraphMotionPrimitives', Logger.level.debug, CpDebug.DBG_PATHFINDER) + self.range = range + self.graph = graph +end + +---@return table [{x, y, d}] array of the next possible entries, their coordinates and +--- the distance to the entry + the length of the edge +function GraphPathfinder.GraphMotionPrimitives:getPrimitives(node) + local primitives = {} + self.logger:trace('\tpredecessor: %.1f %.1f (%.1f)', node.x, node.y, node.g) + for _, edge in ipairs(self.graph) do + local entries = edge:getEntries() + for _, entry in ipairs(entries) do + local distanceToEntry = (node - entry):length() + if distanceToEntry <= self.range then + local exit = edge:getExit(entry) + table.insert(primitives, { x = exit.x, y = exit.y, d = edge:getLength() + distanceToEntry, + edge = edge, entry = entry }) + self.logger:trace('\t primitives: %.1f %.1f', exit.x, exit.y) + end + end + end + return primitives +end + +---@return State3D successor for the given primitive +function GraphPathfinder.GraphMotionPrimitives:createSuccessor(node, primitive, hitchLength) + self.logger:trace('\t\tsuccessor: %.1f %.1f (d=%.1f) from node: %.1f %.1f (g=%.1f, d=%.1f)', + primitive.x, primitive.y, primitive.d, node.x, node.y, node.g, node.d) + return GraphPathfinder.Node(primitive.x, primitive.y, node.g, node, node.d + primitive.d, primitive.edge, primitive.entry) +end + +--- Load a graph from Courseplay.xml, for tests only. +function GraphPathfinder.loadGraphEdgesFromXml(fileName) + local graph = {} + local edge + for line in io.lines(fileName) do + local direction = string.match(line, '') then + -- segment ends + edge:calculateProperties() + table.insert(graph, edge) -- add the edge to the graph + Logger():info('Loaded edge %d direction: %s, length: %.1f', #graph, tostring(edge:getDirection()), edge:getLength()) + end + local x, z = string.match(line, ' 0 and self.iterations < self.maxIterations do - -- pop lowest cost node from queue + -- yield after the configured iterations or after 20 ms + self.iterationsSinceYield = self.iterationsSinceYield + 1 + if (self.iterationsSinceYield % self.yieldAfter == 0 or readIntervalTimerMs(self.timer) > 20) then + self.yields = self.yields + 1 + closeIntervalTimer(self.timer) + -- if we had the coroutine package, we would coursePlayCoroutine.yield(false) here + return PathfinderResult(false) + end -- pop lowest cost node from queue ---@type State3D local pred = State3D.pop(self.openList) - --self:debug('pop %s', tostring(pred)) + self.logger:trace('pop %s', tostring(pred)) if pred:equals(self.goal, self.deltaPosGoal, self.deltaThetaGoal) then -- done! self:debug('Popped the goal (%d).', self.iterations) return self:finishRun(true, self:rollUpPath(pred, self.goal)) end - self.count = self.count + 1 - -- yield after the configured iterations or after 20 ms - if (self.count % self.yieldAfter == 0 or readIntervalTimerMs(self.timer) > 20) then - self.yields = self.yields + 1 - closeIntervalTimer(self.timer) - -- if we had the coroutine package, we would coursePlayCoroutine.yield(false) here - return false - end + if not pred:isClosed() then -- analytical expansion: try a Dubins/Reeds-Shepp path from here randomly, more often as we getting closer to the goal -- also, try it before we start with the pathfinding if pred.h then - if self.analyticSolverEnabled and not self.goalNodeIsInvalid and + if self.analyticSolverEnabled and not self.goalNodeInvalid and math.random() > 2 * pred.h / self.distanceToGoal then self:debug('Check analytic solution at iteration %d, %.1f, %.1f', self.iterations, pred.h, pred.h / self.distanceToGoal) local analyticPath, _, pathType = self:getAnalyticPath(pred, self.goal, self.turnRadius, self.allowReverse, self.hitchLength) @@ -612,7 +615,7 @@ function HybridAStar:run(start, goal, turnRadius, allowReverse, constraints, hit self:debug('Found collision free analytic path (%s) at iteration %d', pathType, self.iterations) -- remove first node of returned analytic path as it is the same as pred table.remove(analyticPath, 1) - -- TODO why are we calling rollUpPath here? + -- roll up the path from the start of the analytic path back to start return self:finishRun(true, self:rollUpPath(pred, self.goal, analyticPath)) end end @@ -624,6 +627,7 @@ function HybridAStar:run(start, goal, turnRadius, allowReverse, constraints, hit if succ:equals(self.goal, self.deltaPosGoal, self.deltaThetaGoal) then succ.pred = succ.pred self:debug('Successor at the goal (%d).', self.iterations) + self:debug('%s', succ) return self:finishRun(true, self:rollUpPath(succ, self.goal)) end @@ -640,16 +644,16 @@ function HybridAStar:run(start, goal, turnRadius, allowReverse, constraints, hit analyticSolutionCost = analyticSolution:getLength(self.turnRadius) succ:updateH(self.goal, analyticSolutionCost) else - succ:updateH(self.goal, 0, succ:distance(self.goal) * 1.5) + succ:updateH(self.goal, 0, succ:distance(self.goal) * 1.0) end - --self:debug(' %s', tostring(succ)) + self.logger:trace(' %s', tostring(succ)) if existingSuccNode then - --self:debug(' existing node %s', tostring(existingSuccNode)) + self.logger:trace(' existing node %s', tostring(existingSuccNode)) -- there is already a node at this (discretized) position -- add a small number before comparing to adjust for floating point calculation differences if existingSuccNode:getCost() + 0.001 >= succ:getCost() then - --self:debug('%.6f replacing %s with %s', succ:getCost() - existingSuccNode:getCost(), tostring(existingSuccNode), tostring(succ)) + self.logger:trace('%.6f replacing %s with %s', succ:getCost() - existingSuccNode:getCost(), tostring(existingSuccNode), tostring(succ)) if self.openList:valueByPayload(existingSuccNode) then -- existing node is on open list already, remove it here, will replace with existingSuccNode:remove(self.openList) @@ -659,7 +663,7 @@ function HybridAStar:run(start, goal, turnRadius, allowReverse, constraints, hit -- add to open list succ:insert(self.openList) else - --self:debug('insert existing node back %s (iteration %d), diff %s', tostring(succ), self.iterations, tostring(succ:getCost() - existingSuccNode:getCost())) + self.logger:trace('insert existing node back %s (iteration %d), diff %s', tostring(succ), self.iterations, tostring(succ:getCost() - existingSuccNode:getCost())) end else -- successor cell does not yet exist @@ -668,13 +672,13 @@ function HybridAStar:run(start, goal, turnRadius, allowReverse, constraints, hit succ:insert(self.openList) end else - --self:debug('Invalid node %s (iteration %d)', tostring(succ), self.iterations) + self.logger:trace('Invalid node %s (iteration %d)', tostring(succ), self.iterations) succ:close() end -- valid node end end -- node as been expanded, close it to prevent expansion again - --self:debug(tostring(pred)) + self.logger:trace(tostring(pred)) pred:close() self.expansions = self.expansions + 1 end diff --git a/scripts/pathfinder/HybridAStarWithAStarInTheMiddle.lua b/scripts/pathfinder/HybridAStarWithAStarInTheMiddle.lua index b6064842c..6cde5bbee 100644 --- a/scripts/pathfinder/HybridAStarWithAStarInTheMiddle.lua +++ b/scripts/pathfinder/HybridAStarWithAStarInTheMiddle.lua @@ -1,9 +1,15 @@ ---- A pathfinder combining the (slow) hybrid A * and the (fast) regular A * star. ---- Near the start and the goal the hybrid A * is used to ensure the generated path is drivable (direction changes ---- always obey the turn radius), but use the A * between the two. ---- We'll run 3 pathfindings: one A * between start and goal (phase 1), then trim the ends of the result in hybridRange ---- Now run a hybrid A * from the start to the beginning of the trimmed A * path (phase 2), then another hybrid A * from the ---- end of the trimmed A * to the goal (phase 3). +--- A pathfinder combining the (slow) hybrid A* and the (fast) regular A* . +--- +--- Near the start and the goal the hybrid A* is used to ensure the generated path is drivable (direction changes +--- always obey the turn radius), but use the A* between the two. +--- +--- We'll run 3 pathfindings: +--- +--- * one A* between start and goal (phase 1), then trim the ends of the result in hybridRange +--- +--- * now run a hybrid A* from the start to the beginning of the trimmed A* path (phase 2), +--- +--- * then another hybrid A* from the end of the trimmed A* to the goal (phase 3). HybridAStarWithAStarInTheMiddle = CpObject(PathfinderInterface) ---@param yieldAfter number coroutine yield after so many iterations (number of iterations in one update loop) @@ -43,12 +49,7 @@ end --- when we search for a valid analytic solution we use this instead of isValidNode() ---@param hitchLength number hitch length of a trailer (length between hitch on the towing vehicle and the --- rear axle of the trailer), can be nil ----@return boolean true if pathfinding is done (success or failure), false means it isn't ready and ---- resume() must be called to continue until this is true ----@return Polyline the path if found ----@return boolean if true, the goal node is invalid (for instance a vehicle or obstacle is there) so ---- the pathfinding can never succeed. ----@return number the furthest distance the pathfinding tried from the start, only when no path found +---@return PathfinderResult function HybridAStarWithAStarInTheMiddle:start(start, goal, turnRadius, allowReverse, constraints, hitchLength) self.startNode, self.goalNode = State3D.copy(start), State3D.copy(goal) self.originalStartNode = State3D.copy(self.startNode) @@ -68,6 +69,7 @@ function HybridAStarWithAStarInTheMiddle:start(start, goal, turnRadius, allowRev end -- distance between start and goal is relatively short, one phase hybrid A* all the way +---@return PathfinderResult function HybridAStarWithAStarInTheMiddle:findHybridStartToEnd() self.phase = self.ALL_HYBRID self:debug('Goal is closer than %d, use one phase pathfinding only', self.hybridRange * 3) @@ -77,6 +79,7 @@ function HybridAStarWithAStarInTheMiddle:findHybridStartToEnd() end -- start and goal far away, this is the hybrid A* from start to the middle section +---@return PathfinderResult function HybridAStarWithAStarInTheMiddle:findPathFromStartToMiddle() self:debug('Finding path between start and middle section...') self.phase = self.START_TO_MIDDLE @@ -88,6 +91,7 @@ function HybridAStarWithAStarInTheMiddle:findPathFromStartToMiddle() end -- start and goal far away, this is the hybrid A* from the middle section to the goal +---@return PathfinderResult function HybridAStarWithAStarInTheMiddle:findPathFromMiddleToEnd() -- generate middle to end self.phase = self.MIDDLE_TO_END @@ -98,47 +102,48 @@ function HybridAStarWithAStarInTheMiddle:findPathFromMiddleToEnd() end --- The resume() of this pathfinder is more complicated as it handles essentially three separate pathfinding runs +---@return PathfinderResult function HybridAStarWithAStarInTheMiddle:resume(...) - local ok, done, path, goalNodeInvalid = coursePlayCoroutine.resume(self.coroutine, self.currentPathfinder, ...) + local ok, result = coursePlayCoroutine.resume(self.coroutine, self.currentPathfinder, ...) if not ok then - print(done) + print(result.done) printCallstack() self:debug('Pathfinding failed') self.coroutine = nil - return PathfinderResult(true, nil, goalNodeInvalid, self.currentPathfinder:getHighestDistance(), + return PathfinderResult(true, nil, result.goalNodeInvalid, self.currentPathfinder:getHighestDistance(), self.constraints) end - if done then + if result.done then self.coroutine = nil if self.phase == self.ALL_HYBRID then - if path then + if result.path then -- start and goal near, just one phase, all hybrid, we are done - return PathfinderResult(true, path) + return PathfinderResult(true, result.path) else self:debug('all hybrid: no path found') - return PathfinderResult(true, nil, goalNodeInvalid, self.currentPathfinder:getHighestDistance(), + return PathfinderResult(true, nil, result.goalNodeInvalid, self.currentPathfinder:getHighestDistance(), self.constraints) end elseif self.phase == self.ASTAR then self.constraints:resetStrictMode() - if not path then + if not result.path then self:debug('fast A*: no path found') - return PathfinderResult(true, nil, goalNodeInvalid, self.currentPathfinder:getHighestDistance(), + return PathfinderResult(true, nil, result.goalNodeInvalid, self.currentPathfinder:getHighestDistance(), self.constraints) end - CourseGenerator.addDebugPolyline(Polyline(path), {1, 0, 0}) - local lMiddlePath = HybridAStar.length(path) + CourseGenerator.addDebugPolyline(Polyline(result.path), {1, 0, 0}) + local lMiddlePath = HybridAStar.length(result.path) self:debug('Direct path is %d m', lMiddlePath) -- do we even need to use the normal A star or the nodes are close enough that the hybrid A star will be fast enough? if lMiddlePath < self.hybridRange * 2 then return self:findHybridStartToEnd() end -- middle part ready, now trim start and end to make room for the hybrid parts - self.middlePath = path + self.middlePath = result.path HybridAStar.shortenStart(self.middlePath, self.hybridRange) HybridAStar.shortenEnd(self.middlePath, self.hybridRange) if #self.middlePath < 2 then - return PathfinderResult(true, nil, goalNodeInvalid, self.currentPathfinder:getHighestDistance(), + return PathfinderResult(true, nil, result.goalNodeInvalid, self.currentPathfinder:getHighestDistance(), self.constraints) end State3D.smooth(self.middlePath) @@ -146,10 +151,10 @@ function HybridAStarWithAStarInTheMiddle:resume(...) State3D.calculateTrailerHeadings(self.middlePath, self.hitchLength, true) return self:findPathFromStartToMiddle() elseif self.phase == self.START_TO_MIDDLE then - if path then - CourseGenerator.addDebugPolyline(Polyline(path), {0, 1, 0}) + if result.path then + CourseGenerator.addDebugPolyline(Polyline(result.path), {0, 1, 0}) -- start and middle sections ready, continue with the piece from the middle to the end - self.path = path + self.path = result.path -- create start point at the last waypoint of middlePath before shortening self.middleToEndStart = State3D.copy(self.middlePath[#self.middlePath]) -- now shorten both ends of middlePath to avoid short fwd/reverse sections due to overlaps (as the @@ -163,26 +168,26 @@ function HybridAStarWithAStarInTheMiddle:resume(...) return self:findPathFromMiddleToEnd() else self:debug('start to middle: no path found') - return PathfinderResult(true, nil, goalNodeInvalid, self.currentPathfinder:getHighestDistance(), + return PathfinderResult(true, nil, result.goalNodeInvalid, self.currentPathfinder:getHighestDistance(), self.constraints) end elseif self.phase == self.MIDDLE_TO_END then - if path then - CourseGenerator.addDebugPolyline(Polyline(path), {0, 0, 1}) + if result.path then + CourseGenerator.addDebugPolyline(Polyline(result.path), {0, 0, 1}) -- last piece is ready, this was generated from the goal point to the end of the middle section so -- first remove the last point of the middle section to make the transition smoother -- and then add the last section in reverse order -- also, for reasons we don't fully understand, this section may have a direction change at the last waypoint, -- so we just ignore the last one - for i = 1, #path do - table.insert(self.path, path[i]) + for i = 1, #result.path do + table.insert(self.path, result.path[i]) end State3D.smooth(self.path) self.constraints:showStatistics() return PathfinderResult(true, self.path) else self:debug('middle to end: no path found') - return PathfinderResult(true, nil, goalNodeInvalid, self.currentPathfinder:getHighestDistance(), + return PathfinderResult(true, nil, result.goalNodeInvalid, self.currentPathfinder:getHighestDistance(), self.constraints) end end diff --git a/scripts/pathfinder/State3D.lua b/scripts/pathfinder/State3D.lua index 02664c3f5..d9b4c3216 100644 --- a/scripts/pathfinder/State3D.lua +++ b/scripts/pathfinder/State3D.lua @@ -45,8 +45,6 @@ function State3D:init(x, y, t, g, pred, gear, steer, tTrailer, d) self.tTrailer = tTrailer and self:normalizeHeadingRad(tTrailer) or 0 self.gear = gear or Gear.Forward self.steer = steer - -- penalty for using this node, to avoid obstacles, stay in an area, etc. - self.nodePenalty = 0 end function State3D.copy(other) @@ -142,10 +140,6 @@ function State3D:updateG(primitive, userPenalty) self.g = self.g + penalty * primitive.d + (userPenalty or 0) end -function State3D:setNodePenalty(nodePenalty) - self.nodePenalty = nodePenalty -end - function State3D:getTrailerHeading() return self.tTrailer end diff --git a/scripts/pathfinder/test/GraphPathfinderTest.lua b/scripts/pathfinder/test/GraphPathfinderTest.lua new file mode 100644 index 000000000..a37edd915 --- /dev/null +++ b/scripts/pathfinder/test/GraphPathfinderTest.lua @@ -0,0 +1,430 @@ +package.path = package.path .. ";../../test/?.lua;../../geometry/?.lua;../../courseGenerator/geometry/?.lua;../../courseGenerator/?.lua;../?.lua;../../?.lua;../../util/?.lua" +lu = require("luaunit") +require('mock-GiantsEngine') +require('mock-Courseplay') +require('CpObject') +require('CpUtil') +require('Logger') +require('BinaryHeap') +require('CpMathUtil') +require('Dubins') +require('ReedsShepp') +require('ReedsSheppSolver') +require('AnalyticSolution') +require('CourseGenerator') +require('WaypointAttributes') +require('Vector') +require('LineSegment') +require('State3D') +require('Vertex') +require('Polyline') +require('Polygon') +require('Slider') +require('WrapAroundIndex') -- for the test cases +require('AnalyticHelper') -- for the test cases +require('PathfinderUtil') +require('HybridAStar') +require('GraphPathfinder') + +local GraphEdge = GraphPathfinder.GraphEdge +local TestConstraints = CpObject(PathfinderConstraintInterface) +local pathfinder, start, goal, done, path, goalNodeInvalid + +local function printPath() + for i, p in ipairs(path) do + print(i, Vector.__tostring(p)) + end +end + +local function runPathfinder() + local result = pathfinder:start(start, goal, 6, false, TestConstraints(), 0) + while not result.done do + result = pathfinder:resume() + end + return result.done, result.path, result.goalNodeInvalid +end + +TestWithoutTransitions = {} +function TestWithoutTransitions:setUp() + self.splitEdges = Polyline.splitEdges + self.ensureMinimumRadius = Polyline.ensureMinimumRadius + -- these create the curves between the graph edges, resulting in many vertices which we don't want to test, + -- so disable and work with just the edges + Polyline.splitEdges = function() end + Polyline.ensureMinimumRadius = function() end +end + +function TestWithoutTransitions:tearDown() + -- restore the Polyline functions + Polyline.splitEdges = self.splitEdges + Polyline.ensureMinimumRadius = self.ensureMinimumRadius +end + +function TestWithoutTransitions:testDirection() + + local graph = { + GraphEdge(GraphEdge.UNIDIRECTIONAL, + { + Vertex(100, 100), + Vertex(110, 100), + Vertex(120, 100) + }), + GraphEdge( + GraphEdge.UNIDIRECTIONAL, + { + Vertex(120, 105), + Vertex(110, 105), + Vertex(100, 105), + }), + } + pathfinder = GraphPathfinder(math.huge, 500, 10, graph) + start = State3D(90, 105, 0, 0) + goal = State3D(130, 105, 0, 0) + done, path, _ = runPathfinder() + lu.assertIsTrue(done) + lu.assertEquals(#path, 3) + -- path contains all points of the edge it goes through + path[1]:assertAlmostEquals(Vector(100, 100)) + path[2]:assertAlmostEquals(Vector(110, 100)) + path[#path]:assertAlmostEquals(Vector(120, 100)) + start, goal = goal, start + done, path, _ = runPathfinder() + lu.assertIsTrue(done) + lu.assertEquals(#path, 3) + path[1]:assertAlmostEquals(Vector(120, 105)) + path[2]:assertAlmostEquals(Vector(110, 105)) + path[#path]:assertAlmostEquals(Vector(100, 105)) + +end + +function TestWithoutTransitions:testBidirectional() + + local graph = { + GraphEdge(GraphEdge.BIDIRECTIONAL, + { + Vertex(120, 100), + Vertex(110, 100), + Vertex(100, 100), + }), + GraphEdge( + GraphEdge.UNIDIRECTIONAL, + { + Vertex(120, 105), + Vertex(110, 105), + Vertex(100, 105), + }), + } + pathfinder = GraphPathfinder(math.huge, 500, 10, graph) + start = State3D(90, 105, 0, 0) + goal = State3D(130, 105, 0, 0) + done, path, _ = runPathfinder() + lu.assertIsTrue(done) + lu.assertEquals(#path, 3) + -- path contains the start node and all points of the edge it goes through + path[1]:assertAlmostEquals(Vector(100, 100)) + path[2]:assertAlmostEquals(Vector(110, 100)) + path[#path]:assertAlmostEquals(Vector(120, 100)) + start, goal = goal, start + done, path, _ = runPathfinder() + lu.assertIsTrue(done) + lu.assertEquals(#path, 3) + path[1]:assertAlmostEquals(Vector(120, 105)) + path[2]:assertAlmostEquals(Vector(110, 105)) + path[#path]:assertAlmostEquals(Vector(100, 105)) + +end + +function TestWithoutTransitions:testShorterPath() + + local graph = { + GraphEdge(GraphEdge.UNIDIRECTIONAL, + { + Vertex(100, 100), + Vertex(110, 100), + Vertex(120, 100) + }), + GraphEdge( + GraphEdge.UNIDIRECTIONAL, + { + Vertex(100, 105), + Vertex(110, 200), + Vertex(120, 105), + }), + } + pathfinder = GraphPathfinder(math.huge, 500, 10, graph) + start = State3D(90, 105, 0, 0) + goal = State3D(130, 105, 0, 0) + done, path, _ = runPathfinder() + lu.assertIsTrue(done) + lu.assertEquals(#path, 3) + path[1]:assertAlmostEquals(Vector(100, 100)) + path[2]:assertAlmostEquals(Vector(110, 100)) + path[#path]:assertAlmostEquals(Vector(120, 100)) + +end + +function TestWithoutTransitions:testRange() + + local graph = { + GraphEdge(GraphEdge.UNIDIRECTIONAL, + { + Vertex(100, 100), + Vertex(110, 100), + Vertex(120, 100) + }), + GraphEdge( + GraphEdge.UNIDIRECTIONAL, + { + Vertex(120, 105), + Vertex(110, 105), + Vertex(100, 105), + }), + GraphEdge(GraphEdge.UNIDIRECTIONAL, + { + Vertex(130, 100), + Vertex(140, 100), + Vertex(150, 100) + }), + } + pathfinder = GraphPathfinder(math.huge, 500, 10, graph) + start = State3D(90, 105, 0, 0) + goal = State3D(150, 105, 0, 0) + done, path, _ = runPathfinder() + lu.assertIsTrue(done) + lu.assertEquals(#path, 6) + path[1]:assertAlmostEquals(Vector(100, 100)) + path[2]:assertAlmostEquals(Vector(110, 100)) + path[#path]:assertAlmostEquals(Vector(150, 100)) + pathfinder = GraphPathfinder(math.huge, 500, 9, graph) + done, path, _ = runPathfinder() + lu.assertIsNil(path) + +end + +function TestWithoutTransitions:testStartInTheMiddle() + + local graph = { + GraphEdge(GraphEdge.BIDIRECTIONAL, + { + Vertex(200, 100), + Vertex(150, 100), + Vertex(100, 100), + }), + GraphEdge( + GraphEdge.UNIDIRECTIONAL, + { + Vertex(200, 105), + Vertex(150, 105), + Vertex(100, 105), + }), + } + pathfinder = GraphPathfinder(math.huge, 500, 10, graph) + start = State3D(150, 95, 0, 0) + goal = State3D(95, 95, 0, 0) + done, path, _ = runPathfinder() + lu.assertIsTrue(done) + lu.assertEquals(#path, 2) + path[1]:assertAlmostEquals(Vector(150, 100)) + path[2]:assertAlmostEquals(Vector(100, 100)) + graph = { + GraphEdge(GraphEdge.BIDIRECTIONAL, + { + Vertex(200, 100), + Vertex(150, 100), + Vertex(100, 100), + }), + GraphEdge( + GraphEdge.UNIDIRECTIONAL, + { + Vertex(200, 105), + Vertex(150, 105), + Vertex(100, 105), + }), + } + pathfinder = GraphPathfinder(math.huge, 500, 10, graph) + start, goal = goal, start + done, path, _ = runPathfinder() + lu.assertIsTrue(done) + lu.assertEquals(#path, 2) + path[1]:assertAlmostEquals(Vector(100, 100)) + path[2]:assertAlmostEquals(Vector(150, 100)) + +end + +function TestWithoutTransitions:testTwoPointSegments() + + local graph = { + GraphEdge(GraphEdge.UNIDIRECTIONAL, + { + Vertex(100, 100), + Vertex(120, 100) + }), + GraphEdge( + GraphEdge.UNIDIRECTIONAL, + { + Vertex(120, 105), + Vertex(100, 105), + }), + } + pathfinder = GraphPathfinder(math.huge, 500, 10, graph) + start = State3D(90, 105, 0, 0) + goal = State3D(130, 105, 0, 0) + done, path, _ = runPathfinder() + lu.assertIsTrue(done) + lu.assertEquals(#path, 2) + path[1]:assertAlmostEquals(Vector(100, 100)) + path[#path]:assertAlmostEquals(Vector(120, 100)) + start, goal = goal, start + done, path, _ = runPathfinder() + lu.assertIsTrue(done) + lu.assertEquals(#path, 2) + path[1]:assertAlmostEquals(Vector(120, 105)) + path[#path]:assertAlmostEquals(Vector(100, 105)) + +end + +function TestWithoutTransitions:testEntryExit() + + local graph = { + GraphEdge(GraphEdge.UNIDIRECTIONAL, + { + Vertex(100, 100), + Vertex(110, 100), + Vertex(120, 100) + }), + GraphEdge( + GraphEdge.UNIDIRECTIONAL, + { + Vertex(120, 105), + Vertex(110, 105), + Vertex(100, 105), + }), + } + -- range is 5 so goal is never within the range of start, that is in the testGoalWithinRange() function + pathfinder = GraphPathfinder(math.huge, 500, 5, graph) + -- start/goal far away + start = State3D(0, 0, 0, 0) + goal = State3D(130, 0, 0, 0) + done, path, _ = runPathfinder() + lu.assertIsTrue(done) + lu.assertEquals(#path, 3) + -- path contains all points of the edge it goes through + path[1]:assertAlmostEquals(Vector(100, 100)) + path[2]:assertAlmostEquals(Vector(110, 100)) + path[#path]:assertAlmostEquals(Vector(120, 100)) + -- start/goal far away + start = State3D(130, 200, 0, 0) + goal = State3D(0, 200, 0, 0) + done, path, _ = runPathfinder() + lu.assertIsTrue(done) + lu.assertEquals(#path, 3) + -- path contains all points of the edge it goes through + path[1]:assertAlmostEquals(Vector(120, 105)) + path[2]:assertAlmostEquals(Vector(110, 105)) + path[#path]:assertAlmostEquals(Vector(100, 105)) + -- start/goal far away, middle entry + start = State3D(110, 0, 0, 0) + goal = State3D(130, 100, 0, 0) + done, path, _ = runPathfinder() + lu.assertIsTrue(done) + lu.assertEquals(#path, 2) + path[1]:assertAlmostEquals(Vector(110, 100)) + path[#path]:assertAlmostEquals(Vector(120, 100)) + +end + +function TestWithoutTransitions:testGoalWithinRange() + -- goal too close to start (graph entry too close to graph exit) + + local graph = { + GraphEdge(GraphEdge.UNIDIRECTIONAL, + { + Vertex(100, 100), + Vertex(120, 100) + }), + } + pathfinder = GraphPathfinder(math.huge, 500, 21, graph) + -- start/goal far away + start = State3D(100, 100, 0, 0) + goal = State3D(120, 100, 0, 0) + done, path, goalNodeInvalid = runPathfinder() + lu.assertIsTrue(done) + lu.assertIsTrue(goalNodeInvalid) + lu.assertIsNil(path) + +end + +function TestWithoutTransitions:testTwoWayStreet() + + local graph = { + -- lane to the right, closer to the start location + GraphEdge(GraphEdge.UNIDIRECTIONAL, + { + Vertex(-100, 10), + Vertex(0, 10), + Vertex(100, 10) + }), + -- lane to the left, shortest way to the goal + GraphEdge(GraphEdge.UNIDIRECTIONAL, + { + Vertex(100, 15), -- 15 here so we can traverse from the other lane to this at x=100 + Vertex(0, 20), + Vertex(-100, 20) + }), + } + -- Range is 5, so we won't turn right, but take the longer path, to the left, make a U turn and drive back on + -- the lane to the left + pathfinder = GraphPathfinder(math.huge, 500, 5, graph) + start = State3D(0, 0, 0, 0) + -- goal on the left + goal = State3D(-120, 10, 0, 0) + done, path, _ = runPathfinder() + lu.assertIsTrue(done) + lu.assertEquals(#path, 5) + path[1]:assertAlmostEquals(Vector(0, 10)) + path[2]:assertAlmostEquals(Vector(100, 10)) + path[3]:assertAlmostEquals(Vector(100, 15)) + path[4]:assertAlmostEquals(Vector(0, 20)) + path[#path]:assertAlmostEquals(Vector(-100, 20)) + -- with the bigger range, we should turn left, taking the shortest path + pathfinder = GraphPathfinder(math.huge, 500, 10, graph) + done, path, _ = runPathfinder() + lu.assertIsTrue(done) + lu.assertEquals(#path, 2) + path[1]:assertAlmostEquals(Vector(0, 20)) + path[#path]:assertAlmostEquals(Vector(-100, 20)) +end + +TestWithTransitions = {} +function TestWithTransitions:testTransition() + local graph = { + GraphEdge(GraphEdge.UNIDIRECTIONAL, + { + Vertex(0, 0), + Vertex(100, 0), + Vertex(200, 0) + }), + GraphEdge( + GraphEdge.UNIDIRECTIONAL, + { + Vertex(210, 10), + Vertex(210, 100), + Vertex(210, 200), + }), + } + pathfinder = GraphPathfinder(math.huge, 500, 20, graph) + start = State3D(-5, 0, 0, 0) + goal = State3D(210, 205, 0, 0) + done, path, _ = runPathfinder() + printPath() + lu.assertIsTrue(done) + lu.assertEquals(#path, 90) + -- path contains all points of the edge it goes through + path[1]:assertAlmostEquals(Vector(0, 0)) + path[41]:assertAlmostEquals(Vector(200, 0)) + -- here's the arch + path[52]:assertAlmostEquals(Vector(210, 10)) + path[#path]:assertAlmostEquals(Vector(210, 200)) +end + +os.exit(lu.LuaUnit.run()) diff --git a/scripts/reloadAI.bat b/scripts/reloadAI.bat index 688de531c..8d32b2cb2 100644 --- a/scripts/reloadAI.bat +++ b/scripts/reloadAI.bat @@ -10,5 +10,6 @@ type ai\strategies\AIDriveStrategyDriveToFieldWorkStart.lua >> %outfile% type ai\strategies\AIDriveStrategyVineFieldWorkCourse.lua >> %outfile% type ai\strategies\AIDriveStrategyFindBales.lua >> %outfile% type ai\strategies\AIDriveStrategyUnloadCombine.lua >> %outfile% +type ai\strategies\AIDriveStrategyStreetDriveToPoint.lua >> %outfile% echo ]]^> >> %outfile% echo ^ >> %outfile% \ No newline at end of file diff --git a/scripts/specializations/CpAIBaleFinder.lua b/scripts/specializations/CpAIBaleFinder.lua index afd6573cd..8a5cefb2a 100644 --- a/scripts/specializations/CpAIBaleFinder.lua +++ b/scripts/specializations/CpAIBaleFinder.lua @@ -125,7 +125,7 @@ function CpAIBaleFinder:getCanStartCpBaleFinder() end function CpAIBaleFinder:getCanStartCp(superFunc) - return superFunc(self) or self:getCanStartCpBaleFinder() and not self:getIsCpCourseRecorderActive() + return superFunc(self) or self:getCanStartCpBaleFinder() end --- Only use the bale finder, if the cp field work job is not possible. diff --git a/scripts/specializations/CpAICombineUnloader.lua b/scripts/specializations/CpAICombineUnloader.lua index e729c6883..65cebe39d 100644 --- a/scripts/specializations/CpAICombineUnloader.lua +++ b/scripts/specializations/CpAICombineUnloader.lua @@ -242,7 +242,7 @@ function CpAICombineUnloader:getCanStartCpCombineUnloader() end function CpAICombineUnloader:getCanStartCp(superFunc) - return superFunc(self) or self:getCanStartCpCombineUnloader() and not self:getIsCpCourseRecorderActive() + return superFunc(self) or self:getCanStartCpCombineUnloader() end function CpAICombineUnloader:getCpStartableJob(superFunc, isStartedByHud) diff --git a/scripts/specializations/CpAIFieldWorker.lua b/scripts/specializations/CpAIFieldWorker.lua index a0c14f502..234b049df 100644 --- a/scripts/specializations/CpAIFieldWorker.lua +++ b/scripts/specializations/CpAIFieldWorker.lua @@ -68,6 +68,9 @@ 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, "getCpFieldWorkerJobParameters", CpAIFieldWorker.getCpFieldWorkerJobParameters) + SpecializationUtil.registerFunction(vehicleType, "getCpFieldWorkerJob", CpAIFieldWorker.getCpFieldWorkerJob) + SpecializationUtil.registerFunction(vehicleType, "applyCpFieldWorkerJobParameters", CpAIFieldWorker.applyCpFieldWorkerJobParameters) end function CpAIFieldWorker.registerOverwrittenFunctions(vehicleType) @@ -134,6 +137,22 @@ function CpAIFieldWorker:getCpLaneOffsetSetting() return spec.cpJob:getCpJobParameters().laneOffset end +function CpAIFieldWorker:getCpFieldWorkerJobParameters() + local spec = CpAIFieldWorker.getSpec(self) + return spec.cpJob:getCpJobParameters() +end + +function CpAIFieldWorker:getCpFieldWorkerJob() + local spec = CpAIFieldWorker.getSpec(self) + return spec.cpJob +end + +function CpAIFieldWorker:applyCpFieldWorkerJobParameters(job) + local spec = CpAIFieldWorker.getSpec(self) + spec.cpJob:getCpJobParameters():validateSettings() + spec.cpJob:copyFrom(job) +end + ------------------------------------------------------------------------------------------------------------------------ --- Interface for other mods, like AutoDrive ------------------------------------------------------------------------------------------------------------------------ @@ -296,7 +315,7 @@ end --- Only allow the basic field work job to start, if a course is assigned. function CpAIFieldWorker:getCanStartCp(superFunc) - return not self:getIsCpCourseRecorderActive() and self:hasCpCourse() and self:getCanStartCpFieldWork() or superFunc(self) + return self:hasCpCourse() and self:getCanStartCpFieldWork() or superFunc(self) end --- Gets the field work job for the hud or start action event. diff --git a/scripts/specializations/CpAIStreetWorker.lua b/scripts/specializations/CpAIStreetWorker.lua new file mode 100644 index 000000000..bf48264d0 --- /dev/null +++ b/scripts/specializations/CpAIStreetWorker.lua @@ -0,0 +1,167 @@ +--- This spec is only for overwriting giants function of the AIFieldWorker. +local modName = CpAIStreetWorker and CpAIStreetWorker.MOD_NAME -- for reload + +---@class CpAIStreetWorker +CpAIStreetWorker = {} + +CpAIStreetWorker.startText = g_i18n:getText("CP_fieldWorkJobParameters_startAt_street") + +CpAIStreetWorker.MOD_NAME = g_currentModName or modName +CpAIStreetWorker.NAME = ".cpAIStreetWorker" +CpAIStreetWorker.SPEC_NAME = CpAIStreetWorker.MOD_NAME .. CpAIStreetWorker.NAME +CpAIStreetWorker.KEY = "."..CpAIStreetWorker.MOD_NAME..CpAIStreetWorker.NAME + +function CpAIStreetWorker.initSpecialization() + local schema = Vehicle.xmlSchemaSavegame + local key = "vehicles.vehicle(?)" .. CpAIStreetWorker.KEY + CpJobParameters.registerXmlSchema(schema, key..".cpJob") +end + +function CpAIStreetWorker.prerequisitesPresent(specializations) + return SpecializationUtil.hasSpecialization(CpAIWorker, specializations) +end + +function CpAIStreetWorker.register(typeManager,typeName,specializations) + if CpAIStreetWorker.prerequisitesPresent(specializations) then + typeManager:addSpecialization(typeName, CpAIStreetWorker.SPEC_NAME) + end +end + +function CpAIStreetWorker.registerEventListeners(vehicleType) + SpecializationUtil.registerEventListener(vehicleType, 'onLoad', CpAIStreetWorker) + SpecializationUtil.registerEventListener(vehicleType, 'onLoadFinished', CpAIStreetWorker) + SpecializationUtil.registerEventListener(vehicleType, 'onReadStream', CpAIStreetWorker) + SpecializationUtil.registerEventListener(vehicleType, 'onWriteStream', CpAIStreetWorker) + SpecializationUtil.registerEventListener(vehicleType, 'onUpdate', CpAIStreetWorker) +end + +function CpAIStreetWorker.registerFunctions(vehicleType) + SpecializationUtil.registerFunction(vehicleType, "getCanStartCpStreetWorker", CpAIStreetWorker.getCanStartCpStreetWorker) + SpecializationUtil.registerFunction(vehicleType, "getCpStreetWorkerJobParameters", CpAIStreetWorker.getCpStreetWorkerJobParameters) + SpecializationUtil.registerFunction(vehicleType, "getCpStreetWorkerJob", CpAIStreetWorker.getCpStreetWorkerJob) + SpecializationUtil.registerFunction(vehicleType, "applyCpStreetWorkerJobParameters", CpAIStreetWorker.applyCpStreetWorkerJobParameters) + +end + +function CpAIStreetWorker.registerOverwrittenFunctions(vehicleType) + SpecializationUtil.registerOverwrittenFunction(vehicleType, 'getCanStartCp', CpAIStreetWorker.getCanStartCp) + SpecializationUtil.registerOverwrittenFunction(vehicleType, 'getCpStartableJob', CpAIStreetWorker.getCpStartableJob) + + SpecializationUtil.registerOverwrittenFunction(vehicleType, 'startCpAtFirstWp', CpAIStreetWorker.startCpAtFirstWp) + SpecializationUtil.registerOverwrittenFunction(vehicleType, 'startCpAtLastWp', CpAIStreetWorker.startCpAtLastWp) +end + +function CpAIStreetWorker.registerEvents(vehicleType) + -- SpecializationUtil.registerEvent(vehicleType, "onCpWrapTypeSettingChanged") +end + +------------------------------------------------------------------------------------------------------------------------ +--- Event listeners +--------------------------------------------------------------------------------------------------------------------------- +function CpAIStreetWorker:onLoad(savegame) + --- Register the spec: spec_cpAIStreetWorker + self.spec_cpAIStreetWorker = self["spec_" .. CpAIStreetWorker.SPEC_NAME] + local spec = self.spec_cpAIStreetWorker + --- This job is for starting the driving with a key bind or the mini gui. + spec.cpJob = g_currentMission.aiJobTypeManager:createJob(AIJobType.STREET_WORKER_CP) + spec.cpJob:setVehicle(self, true) +end + +function CpAIStreetWorker:onLoadFinished(savegame) + local spec = self.spec_cpAIStreetWorker + if savegame ~= nil then + spec.cpJob:loadFromXMLFile(savegame.xmlFile, savegame.key.. CpAIStreetWorker.KEY..".cpJob") + end +end + +function CpAIStreetWorker:onUpdate() + local spec = self.spec_cpAIStreetWorker + if not spec.finishedFirstUpdate then + spec.cpJob:getCpJobParameters():validateSettings() + spec.cpJob:getCpJobParameters():resetToLoadedValue() + spec.finishedFirstUpdate = true + end +end + +function CpAIStreetWorker:saveToXMLFile(xmlFile, baseKey, usedModNames) + local spec = self.spec_cpAIStreetWorker + spec.cpJob:saveToXMLFile(xmlFile, baseKey.. ".cpJob") +end + +function CpAIStreetWorker:onReadStream(streamId, connection) + local spec = self.spec_cpAIStreetWorker + spec.cpJob:readStream(streamId, connection) +end + +function CpAIStreetWorker:onWriteStream(streamId, connection) + local spec = self.spec_cpAIStreetWorker + spec.cpJob:writeStream(streamId, connection) +end + +function CpAIStreetWorker:getCpStreetWorkerJobParameters() + local spec = self.spec_cpAIStreetWorker + return spec.cpJob:getCpJobParameters() +end + +function CpAIStreetWorker:getCpStreetWorkerJob() + local spec = self.spec_cpAIStreetWorker + return spec.cpJob +end + + +function CpAIStreetWorker:applyCpStreetWorkerJobParameters(job) + local spec = self.spec_cpAIStreetWorker + spec.cpJob:getCpJobParameters():validateSettings() + spec.cpJob:copyFrom(job) +end + +--- Is the Street job allowed? +function CpAIStreetWorker:getCanStartCpStreetWorker() + return true +end + +function CpAIStreetWorker:getCanStartCp(superFunc) + return superFunc(self) or self:getCanStartCpStreetWorker() +end + +--- Only use the bale finder, if the cp field work job is not possible. +function CpAIStreetWorker:getCpStartableJob(superFunc, isStartedByHud) + local spec = self.spec_cpAIStreetWorker + if isStartedByHud and self:cpIsHudStreetJobSelected() then + return self:getCanStartCpStreetWorker() and spec.cpJob + end + return superFunc(self, isStartedByHud) or not isStartedByHud and self:getCanStartCpStreetWorker() and spec.cpJob +end + +--- Starts the cp driver at the first waypoint. +function CpAIStreetWorker:startCpAtFirstWp(superFunc) + if not superFunc(self) then + if self:getCanStartCpStreetWorker() then + local spec = self.spec_cpAIStreetWorker + spec.cpJob:applyCurrentState(self, g_currentMission, g_currentMission.playerSystem:getLocalPlayer().farmId, true) + spec.cpJob:setValues() + local success = spec.cpJob:validate(false) + if success then + g_client:getServerConnection():sendEvent(AIJobStartRequestEvent.new(spec.cpJob, self:getOwnerFarmId())) + return true + end + end + else + return true + end +end + +--- Starts the cp driver at the last driven waypoint. +function CpAIStreetWorker:startCpAtLastWp(superFunc) + CpAIStreetWorker.startCpAtFirstWp(self, superFunc) +end + +function CpAIStreetWorker:onCpADStartedByPlayer() + local spec = self.spec_cpAIStreetWorker + --- Applies the bale wrap type set in the hud, so ad can start with the correct type. + +end + +function CpAIStreetWorker:onCpADRestarted() + +end diff --git a/scripts/specializations/CpAIWorker.lua b/scripts/specializations/CpAIWorker.lua index c4bc4313d..1556af1dd 100644 --- a/scripts/specializations/CpAIWorker.lua +++ b/scripts/specializations/CpAIWorker.lua @@ -259,6 +259,10 @@ function CpAIWorker:cpStartStopDriver(isStartedByHud) CpUtil.debugVehicle(CpDebug.DBG_FIELDWORK, self, "Could not find a CP job to start!") return end + if self:getIsCpCourseRecorderActive() then + CpUtil.debugVehicle(CpDebug.DBG_FIELDWORK, self, "Could not start cp, as the course recorder is active!") + return + end if self:getCanStartCp() and job then job:applyCurrentState(self, g_currentMission, g_currentMission.playerSystem:getLocalPlayer().farmId, true, true) diff --git a/scripts/test/mock-Courseplay.lua b/scripts/test/mock-Courseplay.lua index bc499742e..36c910d92 100644 --- a/scripts/test/mock-Courseplay.lua +++ b/scripts/test/mock-Courseplay.lua @@ -23,4 +23,23 @@ CpDebug.getText = function () return '' end g_vehicleConfigurations = {} function g_vehicleConfigurations:get() return false -end \ No newline at end of file +end + +g_Courseplay = { + globalSettings = { + getSettings = function() + return { + deltaAngleRelaxFactorDeg = { + getValue = function() + return 10 + end + }, + maxDeltaAngleAtGoalDeg = { + getValue = function() + return 45 + end + }, + } + end + } +} \ No newline at end of file diff --git a/scripts/test/mock-GiantsEngine.lua b/scripts/test/mock-GiantsEngine.lua index f64745188..88f0cc870 100644 --- a/scripts/test/mock-GiantsEngine.lua +++ b/scripts/test/mock-GiantsEngine.lua @@ -110,3 +110,10 @@ end function printCallstack() end + +CollisionFlag = {} +setmetatable(CollisionFlag, {__index = function() return 0 end}) + +function openIntervalTimer() end +function closeIntervalTimer() end +function readIntervalTimerMs() return 0 end \ No newline at end of file diff --git a/scripts/trigger/TriggerManager.lua b/scripts/trigger/TriggerManager.lua index 20386277f..3fa3ddf58 100644 --- a/scripts/trigger/TriggerManager.lua +++ b/scripts/trigger/TriggerManager.lua @@ -8,6 +8,8 @@ function TriggerManager:init() self.unloadTriggers = {} ---@type table self.dischargeableUnloadTriggers = {} + ---@type table + self.loadTriggers = {} end --- Adds an unload trigger. @@ -33,6 +35,41 @@ function TriggerManager:removeUnloadingSilo(silo) end end +--- Adds an load trigger. +---@param silo table LoadTrigger +function TriggerManager:addLoadingSilo(silo) + if silo.triggerNode ~= nil then + self.loadTriggers[silo.triggerNode] = CpTrigger(silo, silo.triggerNode) + end +end + + +--- Removes the unload trigger, as it got removed for example sold. +---@param silo table LoadTrigger +function TriggerManager:removeLoadingSilo(silo) + if silo.triggerNode ~= nil then + if self.loadTriggers[silo.triggerNode] then + self.loadTriggers[silo.triggerNode]:delete() + self.loadTriggers[silo.triggerNode] = nil + end + end +end + +---@return table +function TriggerManager:getLoadTriggers() + return self.loadTriggers +end + +---@return table +function TriggerManager:getUnloadTriggers() + return self.unloadTriggers +end + +---@return table +function TriggerManager:getDischargeableUnloadTriggers() + return self.dischargeableUnloadTriggers +end + --- Gets the unload trigger from the exactFillRootNode. ---@param node number exactFillRootNode ---@return CpTrigger @@ -40,6 +77,13 @@ function TriggerManager:getUnloadTriggerForNode(node) return self.unloadTriggers[node] end +--- Gets the load trigger from the trigger node. +---@param node number trigger node +---@return CpTrigger +function TriggerManager:getLoadTriggerForNode(node) + return self.loadTriggers[node] +end + --- Gets the first trigger found in the defined area. ---@param triggers table ---@param x number @@ -102,6 +146,20 @@ function TriggerManager:getUnloadTriggerAt(x, z, dirX, dirZ, width, length) return self:getTriggerAt(self.unloadTriggers, x, z, dirX, dirZ, width, length) end +--- Gets the first load trigger found in the defined area. +---@param x number +---@param z number +---@param dirX number +---@param dirZ number +---@param width number +---@param length number +---@return boolean found? +---@return CpTrigger|nil load trigger +---@return table|nil load station/placeable +function TriggerManager:getLoadTriggerAt(x, z, dirX, dirZ, width, length) + return self:getTriggerAt(self.loadTriggers, x, z, dirX, dirZ, width, length) +end + --- Gets the first dischargeable unload trigger found in the defined area. ---@param x number ---@param z number @@ -155,13 +213,23 @@ local function addUnloadingSilo(silo, superFunc, ...) g_triggerManager:addUnloadingSilo(silo) return ret end - UnloadTrigger.load = Utils.overwrittenFunction(UnloadTrigger.load, addUnloadingSilo) local function removeUnloadingSilo(silo, ...) g_triggerManager:removeUnloadingSilo(silo) end - UnloadTrigger.delete = Utils.prependedFunction(UnloadTrigger.delete, removeUnloadingSilo) +local function addLoadingSilo(silo, superFunc, ...) + local ret = superFunc(silo, ...) + g_triggerManager:addLoadingSilo(silo) + return ret +end +LoadTrigger.load = Utils.overwrittenFunction(LoadTrigger.load, addLoadingSilo) + + +local function removeLoadingSilo(silo, ...) + g_triggerManager:removeLoadingSilo(silo) +end +LoadTrigger.delete = Utils.prependedFunction(LoadTrigger.delete, removeLoadingSilo) \ No newline at end of file diff --git a/translations/translation_br.xml b/translations/translation_br.xml index b5b5de0fb..6b707a7ec 100644 --- a/translations/translation_br.xml +++ b/translations/translation_br.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ A rota é salva automaticamente ao fechar o editor e substituir a rota seleciona + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ A rota é salva automaticamente ao fechar o editor e substituir a rota seleciona + @@ -1071,5 +1151,6 @@ Agora sua seleção deve ser semelhante à imagem. + diff --git a/translations/translation_cs.xml b/translations/translation_cs.xml index 4965fa0be..83cdccf87 100644 --- a/translations/translation_cs.xml +++ b/translations/translation_cs.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ + @@ -1035,5 +1115,6 @@ hud还显示助手工作时堆或思洛存储器的剩余填充水平。 + diff --git a/translations/translation_ct.xml b/translations/translation_ct.xml index a27e6975c..3e8919e64 100644 --- a/translations/translation_ct.xml +++ b/translations/translation_ct.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ + @@ -1035,5 +1115,6 @@ Now your selection should look similar to the image. + diff --git a/translations/translation_cz.xml b/translations/translation_cz.xml index 306425d4c..7b1690133 100644 --- a/translations/translation_cz.xml +++ b/translations/translation_cz.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ Trasa se automaticky uloží při zavření editoru a přepíše vybranou trasu. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ Trasa se automaticky uloží při zavření editoru a přepíše vybranou trasu. + @@ -1033,5 +1113,6 @@ Nyní by váš výběr měl vypadat podobně jako na obrázku. + diff --git a/translations/translation_da.xml b/translations/translation_da.xml index 122225470..a2b463f92 100644 --- a/translations/translation_da.xml +++ b/translations/translation_da.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ Ruten gemmes automatisk ved lukning af editoren og tilsidesætter den valgte rut + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ Ruten gemmes automatisk ved lukning af editoren og tilsidesætter den valgte rut + @@ -1046,5 +1126,6 @@ Now your selection should look similar to the image. + diff --git a/translations/translation_de.xml b/translations/translation_de.xml index bf24954fe..c159a3640 100644 --- a/translations/translation_de.xml +++ b/translations/translation_de.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ Der Kurs wird beim Schließen automatisch gespeichert und überschrieben. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ Der Kurs wird beim Schließen automatisch gespeichert und überschrieben. + @@ -1067,5 +1147,6 @@ Das Kreuz sollte jetzt, wie im Bild dargestellt, gelb sein. + diff --git a/translations/translation_ea.xml b/translations/translation_ea.xml index f6e17c282..a52aff8be 100644 --- a/translations/translation_ea.xml +++ b/translations/translation_ea.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ El curso se guarda automáticamente al cerrar el editor y anula el curso selecci + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ El curso se guarda automáticamente al cerrar el editor y anula el curso selecci + @@ -1077,5 +1157,6 @@ Ahora su selección debería verse similar a la imagen. + diff --git a/translations/translation_en.xml b/translations/translation_en.xml index adea7a1e9..e5fba46dd 100644 --- a/translations/translation_en.xml +++ b/translations/translation_en.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ The course is saved automatically on closing of the editor and overrides the sel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ The course is saved automatically on closing of the editor and overrides the sel + @@ -1084,5 +1164,6 @@ Now your selection should look similar to the image. + diff --git a/translations/translation_es.xml b/translations/translation_es.xml index 1f38ab7ee..00d39e4a3 100644 --- a/translations/translation_es.xml +++ b/translations/translation_es.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ El curso se guarda automáticamente al cerrar el editor y anula el curso selecci + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ El curso se guarda automáticamente al cerrar el editor y anula el curso selecci + @@ -1077,5 +1157,6 @@ Ahora su selección debería verse similar a la imagen. + diff --git a/translations/translation_fc.xml b/translations/translation_fc.xml index 2b8f4c758..4ea970efe 100644 --- a/translations/translation_fc.xml +++ b/translations/translation_fc.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ The course is saved automatically on closing of the editor and overrides the sel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ The course is saved automatically on closing of the editor and overrides the sel + @@ -1043,5 +1123,6 @@ Now your selection should look similar to the image. + diff --git a/translations/translation_fi.xml b/translations/translation_fi.xml index 8c8756b9d..168d038b0 100644 --- a/translations/translation_fi.xml +++ b/translations/translation_fi.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ The course is saved automatically on closing of the editor and overrides the sel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ The course is saved automatically on closing of the editor and overrides the sel + @@ -1043,5 +1123,6 @@ Now your selection should look similar to the image. + diff --git a/translations/translation_fr.xml b/translations/translation_fr.xml index 9d5f6a8bf..03840cbee 100644 --- a/translations/translation_fr.xml +++ b/translations/translation_fr.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ La course est automatiquement sauvegardée à la fermeture de l'éditeur et remp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ La course est automatiquement sauvegardée à la fermeture de l'éditeur et remp + @@ -1028,5 +1108,6 @@ Votre sélection devrait ressembler à l'illustration ci-contre. + diff --git a/translations/translation_hu.xml b/translations/translation_hu.xml index f9c817ac7..10cb2528e 100644 --- a/translations/translation_hu.xml +++ b/translations/translation_hu.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ Az útvonal automatikusan mentésre kerül a szerkesztő bezárásakor és felü + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ Az útvonal automatikusan mentésre kerül a szerkesztő bezárásakor és felü + @@ -1052,5 +1132,6 @@ A kijelölésnek hasonlónak kell lennie, mint a képen. + diff --git a/translations/translation_id.xml b/translations/translation_id.xml index c11e7e36c..564591b0e 100644 --- a/translations/translation_id.xml +++ b/translations/translation_id.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ The course is saved automatically on closing of the editor and overrides the sel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ The course is saved automatically on closing of the editor and overrides the sel + @@ -1084,5 +1164,6 @@ Now your selection should look similar to the image. + diff --git a/translations/translation_it.xml b/translations/translation_it.xml index 4f1d0120e..b19fe5c72 100644 --- a/translations/translation_it.xml +++ b/translations/translation_it.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ Il percorso viene salvato automaticamente alla chiusura dell'editor e sovrascriv + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ Il percorso viene salvato automaticamente alla chiusura dell'editor e sovrascriv + @@ -1084,5 +1164,6 @@ Ora la tua selezione dovrebbe essere simile all'immagine. + diff --git a/translations/translation_jp.xml b/translations/translation_jp.xml index 8b840e112..6ac13bc0d 100644 --- a/translations/translation_jp.xml +++ b/translations/translation_jp.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ The course is saved automatically on closing of the editor and overrides the sel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ The course is saved automatically on closing of the editor and overrides the sel + @@ -1042,5 +1122,6 @@ Now your selection should look similar to the image. + diff --git a/translations/translation_kr.xml b/translations/translation_kr.xml index 499eb19f5..d9b0cd51c 100644 --- a/translations/translation_kr.xml +++ b/translations/translation_kr.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -478,6 +506,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -528,6 +607,7 @@ + @@ -1142,5 +1222,6 @@ HUD의 '목표 아이콘'을 사용하여 AI 지도에서 적재 및 하역 위 + diff --git a/translations/translation_nl.xml b/translations/translation_nl.xml index fc76ba9ab..b374a5b20 100644 --- a/translations/translation_nl.xml +++ b/translations/translation_nl.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ The course is saved automatically on closing of the editor and overrides the sel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ The course is saved automatically on closing of the editor and overrides the sel + @@ -1042,5 +1122,6 @@ Now your selection should look similar to the image. + diff --git a/translations/translation_no.xml b/translations/translation_no.xml index 421043be9..ae224fb50 100644 --- a/translations/translation_no.xml +++ b/translations/translation_no.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ The course is saved automatically on closing of the editor and overrides the sel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ The course is saved automatically on closing of the editor and overrides the sel + @@ -1043,5 +1123,6 @@ Now your selection should look similar to the image. + diff --git a/translations/translation_pl.xml b/translations/translation_pl.xml index 4b9de7574..4f9f721c1 100644 --- a/translations/translation_pl.xml +++ b/translations/translation_pl.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ Kurs jest automatycznie zapisywany po zamknięciu edytora i nadpisuje wybrany ku + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ Kurs jest automatycznie zapisywany po zamknięciu edytora i nadpisuje wybrany ku + @@ -1012,5 +1092,6 @@ Twój wybór powinien wyglądać podobnie do tego zdjęcia. + diff --git a/translations/translation_pt.xml b/translations/translation_pt.xml index 4694c61a6..17926eebe 100644 --- a/translations/translation_pt.xml +++ b/translations/translation_pt.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ A rota é guardada automaticamente ao fechar o editor e sobrepõe-se a qualquer + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ A rota é guardada automaticamente ao fechar o editor e sobrepõe-se a qualquer + @@ -1037,5 +1117,6 @@ Now your selection should look similar to the image. + diff --git a/translations/translation_ro.xml b/translations/translation_ro.xml index a9cc0f718..966b4c326 100644 --- a/translations/translation_ro.xml +++ b/translations/translation_ro.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ The course is saved automatically on closing of the editor and overrides the sel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ The course is saved automatically on closing of the editor and overrides the sel + @@ -1043,5 +1123,6 @@ Now your selection should look similar to the image. + diff --git a/translations/translation_ru.xml b/translations/translation_ru.xml index 8f037d698..aa9af271d 100644 --- a/translations/translation_ru.xml +++ b/translations/translation_ru.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ + @@ -1054,5 +1134,6 @@ HUD также показывает оставшийся уровень запо + diff --git a/translations/translation_sv.xml b/translations/translation_sv.xml index 758a0e45a..0fca33334 100644 --- a/translations/translation_sv.xml +++ b/translations/translation_sv.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ Banan sparas automatiskt vid stängning av editorn och åsidosätter den valda b + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ Banan sparas automatiskt vid stängning av editorn och åsidosätter den valda b + @@ -1040,5 +1120,6 @@ Now your selection should look similar to the image. + diff --git a/translations/translation_tr.xml b/translations/translation_tr.xml index 712c69545..3a33e9b0c 100644 --- a/translations/translation_tr.xml +++ b/translations/translation_tr.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ Düzenleyici kapatıldığında rota otomatik olarak kaydedilir ve önceki rotay + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ Düzenleyici kapatıldığında rota otomatik olarak kaydedilir ve önceki rotay + @@ -1084,5 +1164,6 @@ Seçimler tamamlandığında örnek görsele benzer bir görüntü olması gerek + diff --git a/translations/translation_uk.xml b/translations/translation_uk.xml index afae59a1b..6a7ebbcfa 100644 --- a/translations/translation_uk.xml +++ b/translations/translation_uk.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ + @@ -1056,5 +1136,6 @@ CoursePlay здатен розподіляти та пресувати січк + diff --git a/translations/translation_vi.xml b/translations/translation_vi.xml index 6cfade582..972ebe48a 100644 --- a/translations/translation_vi.xml +++ b/translations/translation_vi.xml @@ -28,8 +28,11 @@ + + + - + @@ -62,6 +65,8 @@ + + @@ -69,13 +74,15 @@ - - - - + + + + + + @@ -100,6 +107,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -107,6 +134,7 @@ + @@ -479,6 +507,57 @@ The course is saved automatically on closing of the editor and overrides the sel + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -529,6 +608,7 @@ The course is saved automatically on closing of the editor and overrides the sel + @@ -1084,5 +1164,6 @@ Now your selection should look similar to the image. +