diff --git a/help.txt b/help.txt index b728173120d..9a872012026 100644 --- a/help.txt +++ b/help.txt @@ -134,7 +134,15 @@ Loadouts can be selected from the dropdown in the top middle of the screen. Sele 3) If there is only one set for a set type (except passive tree), e.g. "Default" config set, it will be assigned to all existing loadouts. The "New Loadout" option allows the user to create all four sets from a single popup for convenience. -The "Sync" option is a backup option to force the UI to update in case the user has changed this data behind the scenes. +The "Manage Loadouts" option opens the loadout manager, which works like the set managers found on the Tree, Skills, Items, and Configuration tabs: + - "New" creates all four sets from a single popup, the same as the "New Loadout" option + - "Copy" duplicates all four sets of the selected loadout into a new loadout automatically + - "New/Copy Custom" creates a loadout from a mix of sets, choosing for each of the four whether to start fresh or copy an existing set. It defaults to the sets of the selected loadout, or the currently active sets if none is selected, so saving straight away copies that loadout + - "Rename" renames all four sets of the selected loadout, preserving any identifiers inside braces { } + - A name already used by another loadout or set can't be reused, as loadouts are matched to their sets by name + - "Delete" deletes all four sets of the selected loadout; sets shared with other loadouts, or the last remaining set of a type, are kept + - Double-clicking a loadout activates it + - "Sync" is a backup option to force the UI to update in case the user has changed this data behind the scenes ---[Party Tab] diff --git a/spec/System/TestLoadouts_spec.lua b/spec/System/TestLoadouts_spec.lua new file mode 100644 index 00000000000..6dd66da8117 --- /dev/null +++ b/spec/System/TestLoadouts_spec.lua @@ -0,0 +1,341 @@ +describe("TestLoadouts", function() + -- Returns the loadout with the given name and its index in the loadout list + local function findLoadout(name) + for index, loadout in ipairs(build.loadoutList) do + if loadout.name == name then + return loadout, index + end + end + end + + before_each(function() + newBuild() + end) + + teardown(function() + -- newBuild() takes care of resetting everything in before_each() + end) + + it("creates a new loadout with all four sets", function() + build:NewLoadout("My Loadout") + assert.are.equals("My Loadout", build.treeTab.specList[#build.treeTab.specList].title) + assert.are.equals("My Loadout", build.itemsTab.itemSets[build.itemsTab.itemSetOrderList[#build.itemsTab.itemSetOrderList]].title) + assert.are.equals("My Loadout", build.skillsTab.skillSets[build.skillsTab.skillSetOrderList[#build.skillsTab.skillSetOrderList]].title) + assert.are.equals("My Loadout", build.configTab.configSets[build.configTab.configSetOrderList[#build.configTab.configSetOrderList]].title) + local found = false + for _, loadout in ipairs(build.loadoutList) do + if loadout.name == "My Loadout" then + found = true + end + end + assert.is_true(found) + end) + + it("copies a loadout automatically", function() + build:NewLoadout("My Loadout") + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + local loadout + for _, entry in ipairs(build.loadoutList) do + if entry.name == "My Loadout" then + loadout = entry + end + end + assert.is_not_nil(loadout) + listControl:CopyLoadout(loadout) + local copyFound = false + for _, entry in ipairs(build.loadoutList) do + if entry.name == "My Loadout (copy)" then + copyFound = true + end + end + assert.is_true(copyFound) + -- Copying again picks a unique name + listControl:CopyLoadout(loadout) + local copy2Found = false + for _, entry in ipairs(build.loadoutList) do + if entry.name == "My Loadout (copy 2)" then + copy2Found = true + end + end + assert.is_true(copy2Found) + end) + + it("creates a custom loadout from a mix of new and copied sets", function() + build:NewLoadout("Source") + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + local source + for _, entry in ipairs(build.loadoutList) do + if entry.name == "Source" then + source = entry + end + end + assert.is_not_nil(source) + + -- Copy the tree and item set, but start the skill and config sets fresh + listControl:CreateLoadout("Custom", source.specId, source.itemSetId, nil, nil) + local custom + for _, entry in ipairs(build.loadoutList) do + if entry.name == "Custom" then + custom = entry + end + end + assert.is_not_nil(custom) + -- Every set is newly created, so none are shared with the source loadout + assert.are_not.equals(source.specId, custom.specId) + assert.are_not.equals(source.itemSetId, custom.itemSetId) + assert.are_not.equals(source.skillSetId, custom.skillSetId) + assert.are_not.equals(source.configSetId, custom.configSetId) + assert.are.equals("Custom", build.treeTab.specList[custom.specId].title) + assert.are.equals("Custom", build.itemsTab.itemSets[custom.itemSetId].title) + assert.are.equals("Custom", build.skillsTab.skillSets[custom.skillSetId].title) + assert.are.equals("Custom", build.configTab.configSets[custom.configSetId].title) + end) + + it("creates an all new custom loadout when nothing is copied", function() + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + listControl:CreateLoadout("All New", nil, nil, nil, nil) + local found = false + for _, entry in ipairs(build.loadoutList) do + if entry.name == "All New" then + found = true + end + end + assert.is_true(found) + end) + + it("generates unique default names for custom loadouts", function() + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + assert.are.equals("New Loadout Custom", listControl:UniqueName("New Loadout Custom")) + listControl:CreateLoadout("New Loadout Custom", nil, nil, nil, nil) + assert.are.equals("New Loadout Custom 2", listControl:UniqueName("New Loadout Custom")) + end) + + it("copies the untitled default loadout without erroring", function() + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + -- A fresh build has one untitled set of each type, which resolves to a "Default" loadout + assert.are.equals(1, #build.loadoutList) + assert.are.equals("Default", build.loadoutList[1].name) + listControl:CopyLoadout(build.loadoutList[1]) + local copyFound = false + for _, entry in ipairs(build.loadoutList) do + if entry.name == "Default (copy)" then + copyFound = true + end + end + assert.is_true(copyFound) + end) + + it("deletes a newly added loadout without breaking the remaining sets", function() + build:NewLoadout("Second") + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + assert.are.equals(2, #build.loadoutList) + local second + for _, entry in ipairs(build.loadoutList) do + if entry.name == "Second" then + second = entry + end + end + assert.is_not_nil(second) + + listControl:DeleteLoadout(second) + + assert.are.equals(1, #build.loadoutList) + assert.are.equals(1, #build.treeTab.specList) + assert.are.equals(1, #build.itemsTab.itemSetOrderList) + assert.are.equals(1, #build.skillsTab.skillSetOrderList) + assert.are.equals(1, #build.configTab.configSetOrderList) + -- The active sets must still point at sets that exist + assert.is_not_nil(build.treeTab.specList[build.treeTab.activeSpec]) + assert.is_not_nil(build.itemsTab.itemSets[build.itemsTab.activeItemSetId]) + assert.is_not_nil(build.skillsTab.skillSets[build.skillsTab.activeSkillSetId]) + assert.is_not_nil(build.configTab.configSets[build.configTab.activeConfigSetId]) + end) + + it("keeps sets that are shared with another loadout when deleting", function() + -- Two trees sharing a single item, skill and config set each + build.treeTab.specList[1].title = "Shared A" + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + listControl:AddSpec(1, "Shared B") + build:SyncLoadouts() + assert.are.equals(2, #build.loadoutList) + + local sharedItemSetId = build.itemsTab.itemSetOrderList[1] + local toDelete + for _, entry in ipairs(build.loadoutList) do + if entry.name == "Shared B" then + toDelete = entry + end + end + listControl:DeleteLoadout(toDelete) + + -- The tree is gone, but the single item set is still used by "Shared A" + assert.are.equals(1, #build.treeTab.specList) + assert.is_not_nil(build.itemsTab.itemSets[sharedItemSetId]) + assert.are.equals(1, #build.loadoutList) + end) + + it("resets the dropdown selection after opening the manager", function() + local dropdown = build.controls.buildLoadouts + local manageIndex + for i, value in ipairs(dropdown.list) do + if value == "^7^7Manage Loadouts" then + manageIndex = i + end + end + assert.is_not_nil(manageIndex) + dropdown:SetSel(manageIndex) + -- Selection must fall back to the header, otherwise picking "Manage Loadouts" + -- a second time would not fire the callback again + assert.are.equals(1, dropdown.selIndex) + main:ClosePopup() + end) + + it("resolves link identifier loadouts", function() + build.treeTab.specList[1].title = "Tree {A}" + local itemSet = build.itemsTab.itemSets[build.itemsTab.itemSetOrderList[1]] + itemSet.title = "Items {A}" + -- With only one skill and config set, they are shared by all loadouts + build:SyncLoadouts() + local loadout + for _, entry in ipairs(build.loadoutList) do + if entry.name == "Tree {A}" then + loadout = entry + end + end + assert.is_not_nil(loadout) + assert.are.equals(1, loadout.specId) + assert.are.equals(build.itemsTab.itemSetOrderList[1], loadout.itemSetId) + end) + + it("keeps a linked loadout selected when its tree has several identifiers", function() + -- One tree shared by two loadouts, each with its own item, skill and config set + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + build.treeTab.specList[1].title = "Linked {A,B}" + build.itemsTab.itemSets[build.itemsTab.itemSetOrderList[1]].title = "Items {A}" + build.skillsTab.skillSets[build.skillsTab.skillSetOrderList[1]].title = "Skills {A}" + build.configTab.configSets[build.configTab.configSetOrderList[1]].title = "Config {A}" + listControl:AddItemSet(nil, "Items {B}") + listControl:AddSkillSet(nil, "Skills {B}") + listControl:AddConfigSet(nil, "Config {B}") + build:SyncLoadouts() + local loadoutB, index = findLoadout("Linked {B}") + assert.is_not_nil(loadoutB) + + listControl:SelectIndex(index) + listControl:OnSelClick(index, loadoutB, true) + + assert.are.equals("Linked {B}", listControl.selValue.name) + assert.is_true(listControl:IsActiveLoadout(listControl.selValue)) + end) + + it("keeps the manager's selection on a current row after Sync and New", function() + build:NewLoadout("First") + build:OpenLoadoutManagePopup() + local manager = main.popups[1] + local listControl = manager.controls[1] + listControl:SelectIndex(select(2, findLoadout("First"))) + + -- Sync rebuilds every row, so the selection has to follow the loadout to its new row + manager.controls[2]:Click() + assert.are.equals("First", listControl.selValue.name) + assert.are.equals(listControl.list[listControl.selIndex], listControl.selValue) + listControl:DeleteLoadout(listControl.selValue) + assert.is_nil(findLoadout("First")) + + -- New selects the loadout it creates, like Copy and New/Copy Custom do + listControl.controls.new:Click() + local controls = main.popups[1].controls + controls.edit:SetText("Second", true) + controls.save:Click() + assert.are.equals("Second", listControl.selValue.name) + assert.are.equals(listControl.list[listControl.selIndex], listControl.selValue) + main:ClosePopup() + end) + + it("rejects renaming a loadout to a name already in use", function() + build:NewLoadout("First") + build:NewLoadout("Second") + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + listControl:RenameLoadout(findLoadout("Second")) + local controls = main.popups[1].controls + controls.edit:SetText("First", true) + assert.is_falsy(controls.save:IsEnabled()) + -- Keeping the current name is allowed, as is a free one + controls.edit:SetText("Second", true) + assert.is_truthy(controls.save:IsEnabled()) + controls.edit:SetText("Third", true) + assert.is_truthy(controls.save:IsEnabled()) + main:ClosePopup() + end) + + it("allows renaming a linked loadout to the name of an unlinked one", function() + build:NewLoadout("First") + build.treeTab.specList[1].title = "Linked {A}" + build.itemsTab.itemSets[build.itemsTab.itemSetOrderList[1]].title = "Items {A}" + build.skillsTab.skillSets[build.skillsTab.skillSetOrderList[1]].title = "Skills {A}" + build.configTab.configSets[build.configTab.configSetOrderList[1]].title = "Config {A}" + build:SyncLoadouts() + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + local first = findLoadout("First") + listControl:RenameLoadout(findLoadout("Linked {A}")) + local controls = main.popups[1].controls + controls.edit:SetText("First", true) + assert.is_truthy(controls.save:IsEnabled()) + controls.save:Click() + + -- The sets are tied together by the identifier, so both loadouts keep their own sets + local renamed = findLoadout("First {A}") + assert.is_not_nil(renamed) + assert.are.equals(first.itemSetId, findLoadout("First").itemSetId) + assert.are_not.equals(first.itemSetId, renamed.itemSetId) + end) + + it("rejects new loadouts named after an existing passive tree or set", function() + build:NewLoadout("First") + -- A tree without matching sets is not a loadout, but one of the same name would still clash with it + build.treeTab.specList[1].title = "Unmatched" + build:SyncLoadouts() + assert.is_nil(findLoadout("Unmatched")) + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + + listControl:CreateCustomLoadoutPopup() + local controls = main.popups[1].controls + controls.edit:SetText("First", true) + assert.is_falsy(controls.save:IsEnabled()) + controls.edit:SetText("Unmatched", true) + assert.is_falsy(controls.save:IsEnabled()) + controls.edit:SetText("Third", true) + assert.is_truthy(controls.save:IsEnabled()) + main:ClosePopup() + + build:OpenLoadoutNamePopup() + controls = main.popups[1].controls + controls.edit:SetText("First", true) + assert.is_falsy(controls.save:IsEnabled()) + controls.edit:SetText("Third", true) + assert.is_truthy(controls.save:IsEnabled()) + main:ClosePopup() + end) + + it("defaults a custom loadout to the active sets, or those of the selected loadout", function() + build:NewLoadout("Other") + local listControl = new("LoadoutListControl"):LoadoutListControl(nil, {0, 0, 450, 200}, build) + listControl:CreateCustomLoadoutPopup() + local controls = main.popups[1].controls + assert.are.equals(build.treeTab.activeSpec, controls.treeSelect:GetSelValue().id) + assert.are.equals(build.itemsTab.activeItemSetId, controls.itemSelect:GetSelValue().id) + assert.are.equals(build.skillsTab.activeSkillSetId, controls.skillSelect:GetSelValue().id) + assert.are.equals(build.configTab.activeConfigSetId, controls.configSelect:GetSelValue().id) + main:ClosePopup() + + local other, index = findLoadout("Other") + listControl:SelectIndex(index) + listControl:CreateCustomLoadoutPopup() + controls = main.popups[1].controls + assert.are.equals(other.specId, controls.treeSelect:GetSelValue().id) + assert.are.equals(other.itemSetId, controls.itemSelect:GetSelValue().id) + assert.are.equals(other.skillSetId, controls.skillSelect:GetSelValue().id) + assert.are.equals(other.configSetId, controls.configSelect:GetSelValue().id) + main:ClosePopup() + end) +end) diff --git a/src/Classes/LoadoutListControl.lua b/src/Classes/LoadoutListControl.lua new file mode 100644 index 00000000000..4c3810f43dd --- /dev/null +++ b/src/Classes/LoadoutListControl.lua @@ -0,0 +1,401 @@ +-- Path of Building +-- +-- Class: Loadout List +-- Loadout list control. +-- +local t_insert = table.insert +local t_remove = table.remove +local m_max = math.max + +---@class LoadoutListControl: ListControl +local LoadoutListClass = newClass("LoadoutListControl", "ListControl") + +function LoadoutListClass:LoadoutListControl(anchor, rect, build) + self:ListControl(anchor, rect, 16, "VERTICAL", false, build.loadoutList) + self.build = build + -- The button row spans the full width of the list: four equal buttons plus the wider + -- "New/Copy Custom", separated by 4px gaps + local buttonWidth, buttonGap = 78, 4 + local customWidth = rect[3] - buttonWidth * 4 - buttonGap * 4 + self.controls.new = new("ButtonControl"):ButtonControl({"BOTTOMLEFT",self,"TOPLEFT"}, {0, -4, buttonWidth, 18}, "New", function() + build:OpenLoadoutNamePopup(function(specId) + self:SelectLoadout(specId) + end) + end) + self.controls.rename = new("ButtonControl"):ButtonControl({"LEFT",self.controls.new,"RIGHT"}, {buttonGap, 0, buttonWidth, 18}, "Rename", function() + self:RenameLoadout(self.selValue) + end) + self.controls.rename.enabled = function() + return self.selValue ~= nil + end + self.controls.copy = new("ButtonControl"):ButtonControl({"LEFT",self.controls.rename,"RIGHT"}, {buttonGap, 0, buttonWidth, 18}, "Copy", function() + self:CopyLoadout(self.selValue) + end) + self.controls.copy.enabled = function() + return self.selValue ~= nil + end + self.controls.delete = new("ButtonControl"):ButtonControl({"LEFT",self.controls.copy,"RIGHT"}, {buttonGap, 0, buttonWidth, 18}, "Delete", function() + self:OnSelDelete(self.selIndex, self.selValue) + end) + self.controls.delete.enabled = function() + return self.selValue ~= nil + end + self.controls.custom = new("ButtonControl"):ButtonControl({"LEFT",self.controls.delete,"RIGHT"}, {buttonGap, 0, customWidth, 18}, "New/Copy Custom", function() + self:CreateCustomLoadoutPopup() + end) + return self +end + +-- Returns true if another loadout also uses this set +function LoadoutListClass:IsSetShared(field, setId, loadout) + for _, other in ipairs(self.build.loadoutList) do + if other ~= loadout and other[field] == setId then + return true + end + end + return false +end + +-- Re-selects the loadout using the given passive tree after the list has been rebuilt. A tree with +-- several link identifiers has a row for each, so the identifier picks between them when given +function LoadoutListClass:SelectLoadout(specId, linkId) + self.selIndex = nil + self.selValue = nil + for index, loadout in ipairs(self.list) do + if loadout.specId == specId and (not linkId or loadout.linkId == linkId) then + self.selIndex = index + self.selValue = loadout + break + end + end +end + +-- Returns a loadout name not already in use, appending a numeric suffix if needed +function LoadoutListClass:UniqueName(baseName, copySuffix) + local newName = baseName .. (copySuffix and " (copy)" or "") + local suffix = 1 + while self.build:LoadoutNameInUse(newName) do + suffix = suffix + 1 + newName = baseName .. (copySuffix and (" (copy " .. suffix .. ")") or (" " .. suffix)) + end + return newName +end + +-- Adds a passive tree, either a copy of the given one or a fresh tree, and returns its index +function LoadoutListClass:AddSpec(specId, name) + local build = self.build + local newSpec + if specId then + local spec = build.treeTab.specList[specId] + newSpec = new("PassiveSpec"):PassiveSpec(build, spec.treeVersion) + newSpec.jewels = copyTable(spec.jewels) + newSpec:RestoreUndoState(spec:CreateUndoState()) + newSpec:BuildClusterJewelGraphs() + else + newSpec = new("PassiveSpec"):PassiveSpec(build, latestTreeVersion) + end + newSpec.title = name + t_insert(build.treeTab.specList, newSpec) + build.treeTab.modFlag = true + return #build.treeTab.specList +end + +-- Adds an item set, either a copy of the given one or a fresh set, and returns its id +function LoadoutListClass:AddItemSet(itemSetId, name) + local itemsTab = self.build.itemsTab + local newSet + if itemSetId then + newSet = copyTable(itemsTab.itemSets[itemSetId]) + newSet.id = 1 + while itemsTab.itemSets[newSet.id] do + newSet.id = newSet.id + 1 + end + itemsTab.itemSets[newSet.id] = newSet + else + newSet = itemsTab:NewItemSet() + end + newSet.title = name + t_insert(itemsTab.itemSetOrderList, newSet.id) + itemsTab:AddUndoState() + return newSet.id +end + +-- Adds a skill set, either a copy of the given one or a fresh set, and returns its id +function LoadoutListClass:AddSkillSet(skillSetId, name) + local skillsTab = self.build.skillsTab + local newSet + if skillSetId then + local skillSet = skillsTab.skillSets[skillSetId] + newSet = copyTable(skillSet, true) + newSet.socketGroupList = { } + for _, socketGroup in pairs(skillSet.socketGroupList) do + local newGroup = copyTable(socketGroup, true) + newGroup.gemList = { } + for gemIndex, gem in pairs(socketGroup.gemList) do + newGroup.gemList[gemIndex] = copyTable(gem, true) + end + t_insert(newSet.socketGroupList, newGroup) + end + newSet.id = 1 + while skillsTab.skillSets[newSet.id] do + newSet.id = newSet.id + 1 + end + skillsTab.skillSets[newSet.id] = newSet + else + newSet = skillsTab:NewSkillSet() + end + newSet.title = name + t_insert(skillsTab.skillSetOrderList, newSet.id) + skillsTab:AddUndoState() + return newSet.id +end + +-- Adds a config set, either a copy of the given one or a fresh set, and returns its id +function LoadoutListClass:AddConfigSet(configSetId, name) + local configTab = self.build.configTab + local newSet + if configSetId then + newSet = copyTable(configTab.configSets[configSetId]) + newSet.id = 1 + while configTab.configSets[newSet.id] do + newSet.id = newSet.id + 1 + end + configTab.configSets[newSet.id] = newSet + else + newSet = configTab:NewConfigSet() + end + newSet.title = name + t_insert(configTab.configSetOrderList, newSet.id) + configTab:AddUndoState() + return newSet.id +end + +-- Creates a loadout from the given sets; a nil set id creates a fresh set of that type +function LoadoutListClass:CreateLoadout(name, specId, itemSetId, skillSetId, configSetId) + local specIndex = self:AddSpec(specId, name) + self:AddItemSet(itemSetId, name) + self:AddSkillSet(skillSetId, name) + self:AddConfigSet(configSetId, name) + self.build.modFlag = true + self:UpdateItemsTabPassiveTreeDropdown() + self.build:SyncLoadouts() + self:SelectLoadout(specIndex) +end + +-- Copies all four sets of the given loadout into a new loadout +function LoadoutListClass:CopyLoadout(loadout) + local baseName = (loadout.name:gsub("%s*%{[%w,]+%}", "")) + if baseName == "" then + baseName = "Default" + end + self:CreateLoadout(self:UniqueName(baseName, true), loadout.specId, loadout.itemSetId, loadout.skillSetId, loadout.configSetId) +end + +-- Opens the popup for creating a loadout from a chosen mix of new and existing sets +function LoadoutListClass:CreateCustomLoadoutPopup() + local build = self.build + local controls = { } + + -- The first entry creates a fresh set, the rest copy an existing one + local function buildSetList(orderList, sets) + local list = { { label = "New" } } + for _, setId in ipairs(orderList) do + t_insert(list, { label = sets[setId].title or "Default", id = setId }) + end + return list + end + local treeList = { { label = "New" } } + for specId, spec in ipairs(build.treeTab.specList) do + t_insert(treeList, { + label = (spec.treeVersion ~= latestTreeVersion and ("["..treeVersions[spec.treeVersion].display.."] ") or "")..(spec.title or "Default"), + id = specId, + }) + end + + controls.label = new("LabelControl"):LabelControl(nil, {0, 20, 0, 16}, "^7Enter name for this loadout:") + controls.edit = new("EditControl"):EditControl(nil, {0, 40, 350, 20}, self:UniqueName("New Loadout Custom"), nil, nil, 100, function(buf) + local nameInUse = build:LoadoutNameInUse(buf) + controls.save.enabled = buf:match("%S") and not nameInUse + controls.save.tooltipText = nameInUse and "This name is already used by another loadout or set." or nil + end) + controls.treeSelect = new("DropDownControl"):DropDownControl(nil, {0, 90, 350, 20}, treeList) + controls.treeLabel = new("LabelControl"):LabelControl({"BOTTOMLEFT",controls.treeSelect,"TOPLEFT"}, {0, -4, 0, 16}, "^7Copy from Tree:") + controls.skillSelect = new("DropDownControl"):DropDownControl(nil, {0, 140, 350, 20}, buildSetList(build.skillsTab.skillSetOrderList, build.skillsTab.skillSets)) + controls.skillLabel = new("LabelControl"):LabelControl({"BOTTOMLEFT",controls.skillSelect,"TOPLEFT"}, {0, -4, 0, 16}, "^7Copy from Skill Set:") + controls.itemSelect = new("DropDownControl"):DropDownControl(nil, {0, 190, 350, 20}, buildSetList(build.itemsTab.itemSetOrderList, build.itemsTab.itemSets)) + controls.itemLabel = new("LabelControl"):LabelControl({"BOTTOMLEFT",controls.itemSelect,"TOPLEFT"}, {0, -4, 0, 16}, "^7Copy from Item Set:") + controls.configSelect = new("DropDownControl"):DropDownControl(nil, {0, 240, 350, 20}, buildSetList(build.configTab.configSetOrderList, build.configTab.configSets)) + controls.configLabel = new("LabelControl"):LabelControl({"BOTTOMLEFT",controls.configSelect,"TOPLEFT"}, {0, -4, 0, 16}, "^7Copy from Config Set:") + + -- The sets of the loadout selected in the manager are preselected, or the active sets if none is + -- selected, so saving straight away copies that loadout and each set can be swapped out one at a time + local source = self.selValue or { + specId = build.treeTab.activeSpec, + itemSetId = build.itemsTab.activeItemSetId, + skillSetId = build.skillsTab.activeSkillSetId, + configSetId = build.configTab.activeConfigSetId, + } + controls.treeSelect:SelByValue(source.specId, "id") + controls.skillSelect:SelByValue(source.skillSetId, "id") + controls.itemSelect:SelByValue(source.itemSetId, "id") + controls.configSelect:SelByValue(source.configSetId, "id") + + controls.save = new("ButtonControl"):ButtonControl(nil, {-45, 275, 80, 20}, "Save", function() + self:CreateLoadout(controls.edit.buf, + controls.treeSelect:GetSelValue().id, + controls.itemSelect:GetSelValue().id, + controls.skillSelect:GetSelValue().id, + controls.configSelect:GetSelValue().id) + main:ClosePopup() + end) + controls.cancel = new("ButtonControl"):ButtonControl(nil, {45, 275, 80, 20}, "Cancel", function() + main:ClosePopup() + end) + main:OpenPopup(370, 305, "Create Custom Loadout", controls, "save", "edit", "cancel") +end + +function LoadoutListClass:RenameLoadout(loadout) + local build = self.build + local spec = build.treeTab.specList[loadout.specId] + local controls = { } + local currentName = ((spec.title or "Default"):gsub("%s*%{[%w,]+%}", "")) + controls.label = new("LabelControl"):LabelControl(nil, {0, 20, 0, 16}, "^7Enter new name for this loadout:") + controls.edit = new("EditControl"):EditControl(nil, {0, 40, 350, 20}, currentName, nil, nil, 100, function(buf) + -- Linked loadouts are tied to their sets by identifier rather than by name, so their names can't clash + local nameInUse = not loadout.linkId and build:LoadoutNameInUse(buf, loadout) + controls.save.enabled = buf:match("%S") and not nameInUse + controls.save.tooltipText = nameInUse and "This name is already used by another loadout or set." or nil + end) + controls.save = new("ButtonControl"):ButtonControl(nil, {-45, 70, 80, 20}, "Save", function() + local newName = controls.edit.buf + -- Rename each of the associated sets, preserving any {link} identifiers in their titles + local function newTitle(title) + local linkIdentifier = title and title:match("%{[%w,]+%}") + return linkIdentifier and (newName .. " " .. linkIdentifier) or newName + end + spec.title = newTitle(spec.title) + local itemSet = build.itemsTab.itemSets[loadout.itemSetId] + itemSet.title = newTitle(itemSet.title) + local skillSet = build.skillsTab.skillSets[loadout.skillSetId] + skillSet.title = newTitle(skillSet.title) + local configSet = build.configTab.configSets[loadout.configSetId] + configSet.title = newTitle(configSet.title) + build.treeTab.modFlag = true + build.itemsTab:AddUndoState() + build.skillsTab:AddUndoState() + build.configTab:AddUndoState() + self:UpdateItemsTabPassiveTreeDropdown() + build:SyncLoadouts() + self:SelectLoadout(loadout.specId, loadout.linkId) + main:ClosePopup() + end) + controls.save.enabled = false + controls.cancel = new("ButtonControl"):ButtonControl(nil, {45, 70, 80, 20}, "Cancel", function() + main:ClosePopup() + end) + main:OpenPopup(370, 100, "Rename", controls, "save", "edit", "cancel") +end + +function LoadoutListClass:IsActiveLoadout(loadout) + local build = self.build + return loadout.specId == build.treeTab.activeSpec + and loadout.itemSetId == build.itemsTab.activeItemSetId + and loadout.skillSetId == build.skillsTab.activeSkillSetId + and loadout.configSetId == build.configTab.activeConfigSetId +end + +function LoadoutListClass:GetRowValue(column, index, loadout) + if column == 1 then + return loadout.name .. (self:IsActiveLoadout(loadout) and " ^9(Current)" or "") + end +end + +function LoadoutListClass:OnSelClick(index, loadout, doubleClick) + if doubleClick then + local build = self.build + if loadout.specId ~= build.treeTab.activeSpec then + build.treeTab:SetActiveSpec(loadout.specId) + end + if loadout.itemSetId ~= build.itemsTab.activeItemSetId then + build.itemsTab:SetActiveItemSet(loadout.itemSetId) + end + if loadout.skillSetId ~= build.skillsTab.activeSkillSetId then + build.skillsTab:SetActiveSkillSet(loadout.skillSetId) + end + if loadout.configSetId ~= build.configTab.activeConfigSetId then + build.configTab:SetActiveConfigSet(loadout.configSetId) + end + build:SyncLoadouts() + self:SelectLoadout(loadout.specId, loadout.linkId) + end +end + +-- Deletes the four sets of the given loadout, keeping any that are shared with another +-- loadout or that are the last remaining set of their type +function LoadoutListClass:DeleteLoadout(loadout) + local build = self.build + local treeTab, itemsTab, skillsTab, configTab = build.treeTab, build.itemsTab, build.skillsTab, build.configTab + if #treeTab.specList > 1 and not self:IsSetShared("specId", loadout.specId, loadout) then + t_remove(treeTab.specList, loadout.specId) + if loadout.specId == treeTab.activeSpec then + treeTab:SetActiveSpec(m_max(1, loadout.specId - 1)) + else + treeTab.activeSpec = isValueInArray(treeTab.specList, build.spec) + end + treeTab.modFlag = true + end + if #itemsTab.itemSetOrderList > 1 and not self:IsSetShared("itemSetId", loadout.itemSetId, loadout) then + local setIndex = isValueInArray(itemsTab.itemSetOrderList, loadout.itemSetId) + t_remove(itemsTab.itemSetOrderList, setIndex) + itemsTab.itemSets[loadout.itemSetId] = nil + if loadout.itemSetId == itemsTab.activeItemSetId then + itemsTab:SetActiveItemSet(itemsTab.itemSetOrderList[m_max(1, setIndex - 1)]) + end + itemsTab:AddUndoState() + end + if #skillsTab.skillSetOrderList > 1 and not self:IsSetShared("skillSetId", loadout.skillSetId, loadout) then + local setIndex = isValueInArray(skillsTab.skillSetOrderList, loadout.skillSetId) + t_remove(skillsTab.skillSetOrderList, setIndex) + skillsTab.skillSets[loadout.skillSetId] = nil + if loadout.skillSetId == skillsTab.activeSkillSetId then + skillsTab:SetActiveSkillSet(skillsTab.skillSetOrderList[m_max(1, setIndex - 1)]) + end + skillsTab:AddUndoState() + end + if #configTab.configSetOrderList > 1 and not self:IsSetShared("configSetId", loadout.configSetId, loadout) then + local setIndex = isValueInArray(configTab.configSetOrderList, loadout.configSetId) + t_remove(configTab.configSetOrderList, setIndex) + configTab.configSets[loadout.configSetId] = nil + if loadout.configSetId == configTab.activeConfigSetId then + configTab:SetActiveConfigSet(configTab.configSetOrderList[m_max(1, setIndex - 1)]) + end + configTab:AddUndoState() + end + self.selIndex = nil + self.selValue = nil + self:UpdateItemsTabPassiveTreeDropdown() + build:SyncLoadouts() +end + +function LoadoutListClass:OnSelDelete(index, loadout) + main:OpenConfirmPopup("Delete Loadout", "Are you sure you want to delete '"..loadout.name.."'?\nThis will delete the passive tree, item set, skill set and config set associated with it.\nSets shared with other loadouts, or the last remaining set of a type, will be kept.", "Delete", function() + self:DeleteLoadout(loadout) + end) +end + +function LoadoutListClass:OnSelKeyDown(index, loadout, key) + if key == "F2" then + self:RenameLoadout(loadout) + end +end + +-- Update the passive tree dropdown control in itemsTab +function LoadoutListClass:UpdateItemsTabPassiveTreeDropdown() + local build = self.build + local newSpecList = { } + for i, spec in ipairs(build.treeTab.specList) do + newSpecList[i] = spec.title or "Default" + end + build.itemsTab.controls.specSelect:SetList(newSpecList) + build.itemsTab.controls.specSelect.selIndex = build.treeTab.activeSpec +end diff --git a/src/Modules/Build.lua b/src/Modules/Build.lua index dcfbc74e892..349598c5a9c 100644 --- a/src/Modules/Build.lua +++ b/src/Modules/Build.lua @@ -306,8 +306,8 @@ function buildMode:Init(dbFileName, buildName, buildXML, convertBuild, importLin self.controls.buildLoadouts:SetSel(1) return end - if value == "^7^7Sync" then - self:SyncLoadouts() + if value == "^7^7Manage Loadouts" then + self:OpenLoadoutManagePopup() self.controls.buildLoadouts:SetSel(1) return end @@ -317,40 +317,7 @@ function buildMode:Init(dbFileName, buildName, buildXML, convertBuild, importLin return end if value == "^7^7New Loadout" then - local controls = { } - controls.label = new("LabelControl"):LabelControl(nil, {0, 20, 0, 16}, "^7Enter name for this loadout:") - controls.edit = new("EditControl"):EditControl(nil, {0, 40, 350, 20}, "New Loadout", nil, nil, 100, function(buf) - controls.save.enabled = buf:match("%S") - end) - controls.save = new("ButtonControl"):ButtonControl(nil, {-45, 70, 80, 20}, "Save", function() - local loadout = controls.edit.buf - - local newSpec = new("PassiveSpec"):PassiveSpec(self, latestTreeVersion) - newSpec.title = loadout - t_insert(self.treeTab.specList, newSpec) - - local itemSet = self.itemsTab:NewItemSet(#self.itemsTab.itemSets + 1) - t_insert(self.itemsTab.itemSetOrderList, itemSet.id) - itemSet.title = loadout - - local skillSet = self.skillsTab:NewSkillSet(#self.skillsTab.skillSets + 1) - t_insert(self.skillsTab.skillSetOrderList, skillSet.id) - skillSet.title = loadout - - local configSet = self.configTab:NewConfigSet(#self.configTab.configSets + 1) - t_insert(self.configTab.configSetOrderList, configSet.id) - configSet.title = loadout - - self:SyncLoadouts() - self.modFlag = true - main:ClosePopup() - end) - controls.save.enabled = false - controls.cancel = new("ButtonControl"):ButtonControl(nil, {45, 70, 80, 20}, "Cancel", function() - main:ClosePopup() - end) - main:OpenPopup(370, 100, "Set Name", controls, "save", "edit", "cancel") - + self:OpenLoadoutNamePopup() self.controls.buildLoadouts:SetSel(1) return end @@ -758,6 +725,9 @@ end function buildMode:SyncLoadouts() self.controls.buildLoadouts.list = {"No Loadouts"} + -- Resolved loadouts (name plus the ids of the four associated sets), used by the loadout manager + self.loadoutList = self.loadoutList or { } + wipeTable(self.loadoutList) local filteredList = {"^7^7Loadouts:"} local treeList = {} @@ -797,7 +767,7 @@ function buildMode:SyncLoadouts() transferTable = {} end else - t_insert(treeList, (spec.treeVersion ~= latestTreeVersion and ("["..treeVersions[spec.treeVersion].display.."] ") or "")..(specTitle)) + t_insert(treeList, { specId = id, name = (spec.treeVersion ~= latestTreeVersion and ("["..treeVersions[spec.treeVersion].display.."] ") or "")..(specTitle) }) end end @@ -822,7 +792,7 @@ function buildMode:SyncLoadouts() transferTable = {} end else - setList[setTitle] = true + setList[setTitle] = set end end end @@ -832,15 +802,31 @@ function buildMode:SyncLoadouts() -- loop over all for exact match loadouts for id, tree in ipairs(treeList) do - if (oneItem or itemList[tree]) and (oneSkill or skillList[tree]) and (oneConfig or configList[tree]) then - t_insert(filteredList, tree) + local itemSetId = oneItem and self.itemsTab.itemSetOrderList[1] or itemList[tree.name] + local skillSetId = oneSkill and self.skillsTab.skillSetOrderList[1] or skillList[tree.name] + local configSetId = oneConfig and self.configTab.configSetOrderList[1] or configList[tree.name] + if itemSetId and skillSetId and configSetId then + t_insert(filteredList, tree.name) + t_insert(self.loadoutList, { name = tree.name, specId = tree.specId, itemSetId = itemSetId, skillSetId = skillSetId, configSetId = configSetId }) end end -- loop over the identifiers found within braces and set the loadout name to the TreeSet for _, tree in ipairs(sortedTreeListSpecialLinks) do local treeLinkId = tree.linkId - if ((oneItem or self.itemListSpecialLinks[treeLinkId]) and (oneSkill or self.skillListSpecialLinks[treeLinkId]) and (oneConfig or self.configListSpecialLinks[treeLinkId])) then - t_insert(filteredList, tree.setName .." {"..treeLinkId.."}") + local itemLink = self.itemListSpecialLinks[treeLinkId] + local skillLink = self.skillListSpecialLinks[treeLinkId] + local configLink = self.configListSpecialLinks[treeLinkId] + if ((oneItem or itemLink) and (oneSkill or skillLink) and (oneConfig or configLink)) then + local loadoutName = tree.setName .." {"..treeLinkId.."}" + t_insert(filteredList, loadoutName) + t_insert(self.loadoutList, { + name = loadoutName, + specId = tree.setId, + linkId = treeLinkId, + itemSetId = oneItem and self.itemsTab.itemSetOrderList[1] or itemLink.setId, + skillSetId = oneSkill and self.skillsTab.skillSetOrderList[1] or skillLink.setId, + configSetId = oneConfig and self.configTab.configSetOrderList[1] or configLink.setId, + }) end end end @@ -848,7 +834,7 @@ function buildMode:SyncLoadouts() -- giving the options unique formatting so it can not match with user-created sets t_insert(filteredList, "^7^7-----") t_insert(filteredList, "^7^7New Loadout") - t_insert(filteredList, "^7^7Sync") + t_insert(filteredList, "^7^7Manage Loadouts") t_insert(filteredList, "^7^7Help >>") if #filteredList > 0 then @@ -883,6 +869,95 @@ function buildMode:SyncLoadouts() return treeList, itemList, skillList, configList end +-- Returns true if a passive tree, item set, skill set or config set, other than those of the given loadout, +-- already has this name. Loadouts are matched to their sets by name, so reusing one can tie a loadout to the wrong sets +function buildMode:LoadoutNameInUse(name, loadout) + for specId, spec in ipairs(self.treeTab.specList) do + if (spec.title or "Default") == name and not (loadout and loadout.specId == specId) then + return true + end + end + local function setNameInUse(orderList, sets, ownSetId) + for _, setId in ipairs(orderList) do + if (sets[setId].title or "Default") == name and setId ~= ownSetId then + return true + end + end + return false + end + return setNameInUse(self.itemsTab.itemSetOrderList, self.itemsTab.itemSets, loadout and loadout.itemSetId) + or setNameInUse(self.skillsTab.skillSetOrderList, self.skillsTab.skillSets, loadout and loadout.skillSetId) + or setNameInUse(self.configTab.configSetOrderList, self.configTab.configSets, loadout and loadout.configSetId) +end + +-- Creates a new loadout: a passive tree, item set, skill set and config set sharing the given name. +-- Returns the index of the new passive tree +function buildMode:NewLoadout(loadoutName) + local newSpec = new("PassiveSpec"):PassiveSpec(self, latestTreeVersion) + newSpec.title = loadoutName + t_insert(self.treeTab.specList, newSpec) + + local itemSet = self.itemsTab:NewItemSet(#self.itemsTab.itemSets + 1) + t_insert(self.itemsTab.itemSetOrderList, itemSet.id) + itemSet.title = loadoutName + + local skillSet = self.skillsTab:NewSkillSet(#self.skillsTab.skillSets + 1) + t_insert(self.skillsTab.skillSetOrderList, skillSet.id) + skillSet.title = loadoutName + + local configSet = self.configTab:NewConfigSet(#self.configTab.configSets + 1) + t_insert(self.configTab.configSetOrderList, configSet.id) + configSet.title = loadoutName + + self:SyncLoadouts() + self.modFlag = true + return #self.treeTab.specList +end + +-- Opens the naming popup for a new loadout, passing the new passive tree's index to onCreate if given +function buildMode:OpenLoadoutNamePopup(onCreate) + local controls = { } + controls.label = new("LabelControl"):LabelControl(nil, {0, 20, 0, 16}, "^7Enter name for this loadout:") + controls.edit = new("EditControl"):EditControl(nil, {0, 40, 350, 20}, "New Loadout", nil, nil, 100, function(buf) + local nameInUse = self:LoadoutNameInUse(buf) + controls.save.enabled = buf:match("%S") and not nameInUse + controls.save.tooltipText = nameInUse and "This name is already used by another loadout or set." or nil + end) + controls.save = new("ButtonControl"):ButtonControl(nil, {-45, 70, 80, 20}, "Save", function() + local specId = self:NewLoadout(controls.edit.buf) + main:ClosePopup() + if onCreate then + onCreate(specId) + end + end) + controls.save.enabled = false + controls.cancel = new("ButtonControl"):ButtonControl(nil, {45, 70, 80, 20}, "Cancel", function() + main:ClosePopup() + end) + main:OpenPopup(370, 100, "Set Name", controls, "save", "edit", "cancel") +end + +-- Opens the loadout manager +function buildMode:OpenLoadoutManagePopup() + local loadoutList = new("LoadoutListControl"):LoadoutListControl(nil, {0, 50, 450, 200}, self) + local syncButton = new("ButtonControl"):ButtonControl(nil, {-50, 260, 90, 20}, "Sync", function() + local selected = loadoutList.selValue + self:SyncLoadouts() + -- Syncing rebuilds every row, so move the selection onto the new row for the same loadout + if selected then + loadoutList:SelectLoadout(selected.specId, selected.linkId) + end + end) + syncButton.tooltipText = "Force the loadout list to update in case sets have been changed behind the scenes." + main:OpenPopup(470, 290, "Manage Loadouts", { + loadoutList, + syncButton, + new("ButtonControl"):ButtonControl(nil, {50, 260, 90, 20}, "Done", function() + main:ClosePopup() + end), + }) +end + function buildMode:EstimatePlayerProgress() if self.spec then local PointsUsed, AscUsed, SecondaryAscUsed = self.spec:CountAllocNodes()