diff --git a/data/lang/equipment-core/en.json b/data/lang/equipment-core/en.json index 42a9f0868f..50bb87e17e 100644 --- a/data/lang/equipment-core/en.json +++ b/data/lang/equipment-core/en.json @@ -43,6 +43,14 @@ "description": "", "message": "2MW rapid-fire beam laser" }, + "BY_ITEM": { + "description": "Button in the start menu when editing equipment", + "message": "By item" + }, + "BY_SLOT": { + "description": "Button in the start menu when editing equipment", + "message": "By slot" + }, "CABINS": { "description": "Category header for pressurized cabin slots", "message": "Cabins" @@ -163,10 +171,22 @@ "description": "", "message": "Activate Electronic Countermeasures" }, + "EDIT_EQUIPMENT": { + "description": "Dialog title", + "message": "Edit equipment" + }, + "EMPTY_EQUIPMENT_PROVIDING_SLOTS": { + "description": "Tooltip for wrong equipment item", + "message": "Empty equipment providing slots" + }, "EMPTY_SLOT": { "description": "Text indicating an empty equipment slot.", "message": "EMPTY" }, + "EQUIPMENT_DOES_NOT_FIT_INTO_THE_SLOT": { + "description": "Tooltip for wrong equipment item", + "message": "Equipment does not fit into the slot" + }, "EQUIPMENT_INTEGRITY": { "description": "Tooltip explaining remaining equipment integrity", "message": "Equipment Integrity" @@ -295,6 +315,14 @@ "description": "", "message": "Analyze hyperspace clouds to determine destination and time of arrival or departure." }, + "INVALID_SLOT": { + "description": "Tooltip for wrong equipment item", + "message": "Invalid slot" + }, + "ITEM": { + "description": "A piece of equipment", + "message": "Item" + }, "LARGE_PLASMA_ACCEL": { "description": "", "message": "Large plasma accelerator" @@ -527,6 +555,14 @@ "message": "Mass-produced by local manufacturers on most worlds. These range from being lovingly hand-crafted by artisans on developing worlds to being mindlessly churned out by the millions in automated mega-factories on hyper-industrialised ones.\nBut regardless from where they are sourced, the specifications are tightly controlled by the ISAEEE and should work in most compatible ship systems." }, + "NO_SUITABLE_EQUIPMENT": { + "description": "Message in the start menu when editing equipment", + "message": "No suitable equipment" + }, + "NO_SUITABLE_SLOTS": { + "description": "Message in the start menu when editing equipment", + "message": "No suitable slots" + }, "OCCUPIED_BERTHS": { "description": "Label for the number of passenger berths currently occupied in a cabin equipment item", "message": "Occupied Berths" @@ -637,6 +673,10 @@ "description": "Name for an equipment item that reinforces the hull's superstructure", "message": "Reinforced Structure" }, + "REQUIRED_SLOT_IS_EMPTY": { + "description": "Tooltip for wrong equipment item", + "message": "Required slot is empty" + }, "SCAN_COMPLETED": { "description": "", "message": "Scanning completed" @@ -781,6 +821,14 @@ "description": "", "message": "Used to remotely inspect the equipment, cargo and state of other ships." }, + "TARGET_SLOT_IS_NOT_EMPTY_PUT_THE_REPLACED_ONE_IN_THE_CLIPBOARD_QUESTION": { + "description": "Message in the start menu when editing equipment", + "message": "Target slot is not empty, put the replaced one in the clipboard?" + }, + "THE_SAME_EQUIPMENT_HAS_BEEN_ALREADY_INSTALLED": { + "description": "Message in the start menu when editing equipment", + "message": "The same equipment has been already installed." + }, "THRUSTERS_DEFAULT": { "description": "Equipment name for default RCS thrusters", "message": "Default Thrusters" @@ -840,5 +888,17 @@ "WEAPONS": { "description": "Category name of weapon-related equipment", "message": "Weapons" + }, + "SLOT": { + "description": "", + "message": "Slot" + }, + "SLOT_IS_NOT_EMPTY_REPLACE_THE_EQUIPMENT_QUESTION": { + "description": "", + "message": "Slot is not empty, replace the equipment?" + }, + "SLOTLESS": { + "description": "", + "message": "Slotless" } } diff --git a/data/lang/ui-core/en.json b/data/lang/ui-core/en.json index bc28a623a2..016a411a23 100644 --- a/data/lang/ui-core/en.json +++ b/data/lang/ui-core/en.json @@ -315,6 +315,10 @@ "description": "", "message": "Control Options" }, + "COPY": { + "description": "As for clipboard", + "message": "Copy" + }, "COULD_NOT_DELETE_SAVE": { "description": "", "message": "Could not delete save" @@ -387,6 +391,10 @@ "description": "For hyperjump planner", "message": "Current system" }, + "CUT": { + "description": "As for clipboard", + "message": "Cut" + }, "DANGEROUS": { "description": "Player combat rating", "message": "Dangerous" @@ -627,6 +635,10 @@ "description": "When failed mission", "message": "Failed" }, + "FAILED_TO_RECOVER_SOME_EQUIPMENT": { + "description": "", + "message": "Failed to recover some equipment" + }, "FEATURE_ACCESSORIES": { "description": "Face feature for FaceGenerator in the PersonalInfo view", "message": "Accessories" @@ -1315,6 +1327,10 @@ "description": "Ship jump status", "message": "Initiated" }, + "INSTALL": { + "description": "Install something", + "message": "Install" + }, "INSTALLED": { "description": "Label indicating something is installed", "message": "Installed" @@ -1879,6 +1895,10 @@ "description": "Entry for ship info", "message": "Passenger cabin capacity" }, + "PASTE": { + "description": "As for clipboard", + "message": "Paste" + }, "PATH_FOUND_BUT_VERIFICATION_FAILED": { "description": "Message when restoring savegame", "message": "Path found but verification failed: %s" diff --git a/data/libs/autoload.lua b/data/libs/autoload.lua index 7268ec712f..2e43ecbf5c 100644 --- a/data/libs/autoload.lua +++ b/data/libs/autoload.lua @@ -98,6 +98,27 @@ table.merge = function(a, b, transformer) return a end +-- Copy table recursively using pairs() +-- +-- Does not copy metatable +---@generic T +---@param t T +---@return T +table.deepcopy = function(t) + + if not t then return nil end + + local result = {} + for k, v in pairs(t) do + if type(v) == 'table' then + result[k] = table.deepcopy(v) + else + result[k] = v + end + end + return result +end + -- Append array b to array a -- -- Does not copy metatable nor recurse into the table. diff --git a/data/pigui/libs/forwarded.lua b/data/pigui/libs/forwarded.lua index 177b4f9633..fed47f84b8 100644 --- a/data/pigui/libs/forwarded.lua +++ b/data/pigui/libs/forwarded.lua @@ -10,6 +10,8 @@ local pigui = Engine.pigui ---@class ui local ui = {} +ui.raw = pigui + ui.calcTextAlignment = pigui.CalcTextAlignment ui.alignTextToLineHeight = pigui.AlignTextToLineHeight ui.alignTextToFramePadding = pigui.AlignTextToFramePadding @@ -20,6 +22,7 @@ ui.screenHeight = pigui.screen_height ui.bringWindowToDisplayFront = pigui.bringWindowToDisplayFront ---@type fun() +ui.setKeyboardFocusHere = pigui.SetKeyboardFocusHere ---@type fun(offset: number?) -- Return the size of the specified window's contents from last frame (without padding/decoration) -- Returns {0,0} if the window hasn't been submitted during the lifetime of the program ui.getWindowContentSize = pigui.GetWindowContentSize ---@type fun(name: string): Vector2 @@ -127,7 +130,9 @@ ui.playSfx = pigui.PlaySfx ui.isItemHovered = pigui.IsItemHovered ui.isItemActive = pigui.IsItemActive ui.isItemClicked = pigui.IsItemClicked -ui.isWindowHovered = pigui.IsWindowHovered +ui.isAnyItemActive = pigui.IsAnyItemActive ---@type fun() +ui.isWindowHovered = pigui.IsWindowHovered ---@type fun(flags: any) +ui.isWindowFocused = pigui.IsWindowFocused ---@type fun(flags: any) ui.vSliderInt = pigui.VSliderInt ---@type fun(l: string, size: Vector2, v: integer, min: integer, max: integer, fmt: string?): value:integer, changed:boolean ui.sliderInt = pigui.SliderInt ---@type fun(l: string, v: integer, min: integer, max: integer, fmt: string?): value:integer, changed:boolean ui.colorEdit = pigui.ColorEdit diff --git a/data/pigui/libs/message-box.lua b/data/pigui/libs/message-box.lua index e3463b06ca..e8399e1316 100644 --- a/data/pigui/libs/message-box.lua +++ b/data/pigui/libs/message-box.lua @@ -8,6 +8,10 @@ local lui = Lang.GetResource("ui-core") local font = ui.fonts.pionillium.body local msgButtonWidth = font.size * 7 +-- +-- Interface: msgbox +-- + local msgbox = {} local function createBoxModal(msg, footer, footerWidth) @@ -36,6 +40,11 @@ local function createBoxModal(msg, footer, footerWidth) end):open() end +-- +-- Function: msgbox.OK +-- +-- msgbox.OK(msg) +-- msgbox.OK = function(msg) createBoxModal(msg, function(self) if ui.button(lui.OK, Vector2(msgButtonWidth, 0)) then @@ -44,6 +53,11 @@ msgbox.OK = function(msg) end, msgButtonWidth) end +-- +-- Function: msgbox.OK_CANCEL +-- +-- msgbox.OK_CANCEL(msg) +-- msgbox.OK_CANCEL = function(msg, callback) createBoxModal(msg, function(self) if ui.button(lui.OK, Vector2(msgButtonWidth, 0)) then @@ -59,4 +73,57 @@ msgbox.OK_CANCEL = function(msg, callback) end, msgButtonWidth * 2 + ui.getItemSpacing().x) end +-- +-- Function: msgbox.YES_NO +-- +-- msgbox.YES_NO(msg) +-- +msgbox.YES_NO = function(msg, callbacks) + createBoxModal(msg, function(self) + if ui.button(lui.YES, Vector2(msgButtonWidth, 0)) then + if callbacks and callbacks.yes then + callbacks.yes() + end + self:close() + end + ui.sameLine() + if ui.button(lui.NO, Vector2(msgButtonWidth, 0)) then + if callbacks and callbacks.no then + callbacks.no() + end + self:close() + end + end, msgButtonWidth * 2 + ui.getItemSpacing().x) +end + +-- +-- Function: msgbox.custom +-- +-- msgbox.custom(msg, buttons) +-- +-- Show a message box with custom buttons and corresponding callbacks. +-- +-- Parameters: +-- +-- msg - string, message for message box +-- buttons - array of type: +-- label - string, button label +-- callback - function +-- +msgbox.custom = function(msg, buttons) + createBoxModal(msg, function(self) + for i, button in ipairs(buttons) do + + if i ~= 1 then ui.sameLine() end + + if ui.button(button.label, Vector2(msgButtonWidth, 0)) then + if button.callback then + button.callback() + end + self:close() + end + end + end, msgButtonWidth * #buttons + ui.getItemSpacing().x) +end + return msgbox diff --git a/data/pigui/libs/modal-win.lua b/data/pigui/libs/modal-win.lua index cc4fc11e5b..ca0e058d38 100644 --- a/data/pigui/libs/modal-win.lua +++ b/data/pigui/libs/modal-win.lua @@ -92,6 +92,10 @@ local function drawModals(idx) end end +function ModalWindow:topmost() + return self.stackIdx == #modalStack +end + ui.registerModule('modal', function() drawModals(1) end) diff --git a/data/pigui/modules/new-game-window/class.lua b/data/pigui/modules/new-game-window/class.lua index 2512e7edea..a2f769bad0 100644 --- a/data/pigui/modules/new-game-window/class.lua +++ b/data/pigui/modules/new-game-window/class.lua @@ -19,24 +19,10 @@ local Layout = require 'pigui.modules.new-game-window.layout' local Recovery = require 'pigui.modules.new-game-window.recovery' local StartVariants = require 'pigui.modules.new-game-window.start-variants' local FlightLogParam = require 'pigui.modules.new-game-window.flight-log' -local Helpers = require 'pigui.modules.new-game-window.helpers' local Game = require 'Game' local profileCombo = { items = {}, selected = 0 } -local equipment2 = { - computer_1 = "misc.autopilot", - laser_front_s2 = "laser.pulsecannon_1mw", - shield_s1_1 = "shield.basic_s1", - shield_s1_2 = "shield.basic_s1", - sensor = "sensor.radar", - hull_mod = "hull.atmospheric_shielding", - hyperdrive = "hyperspace.hyperdrive_2", - thruster = "thruster.default_s1", - missile_bay_1 = "missile_bay.opli_internal_s2", - missile_bay_2 = "missile_bay.opli_internal_s2", -} - StartVariants.register({ name = lui.START_AT_MARS, desc = lui.START_AT_MARS_DESC, @@ -44,12 +30,17 @@ StartVariants.register({ logmsg = lui.START_LOG_ENTRY_1, shipType = 'coronatrix', money = 600, - hyperdrive = true, - equipment = { - -- { laser.pulsecannon_1mw, 1 }, - -- { misc.atmospheric_shielding, 1 }, - -- { misc.autopilot, 1 }, - -- { misc.radar, 1 } + equipment = { + computer_1 = "misc.autopilot", + laser_front_s2 = "laser.pulsecannon_1mw", + shield_s1_1 = "shield.basic_s1", + shield_s1_2 = "shield.basic_s1", + sensor = "sensor.radar", + hull_mod = "hull.atmospheric_shielding", + hyperdrive = "hyperspace.hyperdrive_2", + thruster = "thruster.default_s1", + missile_bay_1 = "missile_bay.opli_internal_s2", + missile_bay_2 = "missile_bay.opli_internal_s2", }, cargo = { { Commodities.hydrogen, 2 } @@ -66,11 +57,13 @@ StartVariants.register({ shipType = 'pumpkinseed', money = 400, hyperdrive = true, - equipment = { - -- { laser.pulsecannon_1mw, 1 }, - -- { misc.atmospheric_shielding, 1 }, - -- { misc.autopilot, 1 }, - -- { misc.radar, 1 } + equipment = { + computer_1 = "misc.autopilot", + laser_front_s1 = "laser.pulsecannon_1mw", + sensor = "sensor.radar", + hull_mod = "hull.atmospheric_shielding", + hyperdrive = "hyperspace.hyperdrive_1", + thruster = "thruster.default_s1", }, cargo = { { Commodities.hydrogen, 2 } @@ -87,10 +80,11 @@ StartVariants.register({ shipType = 'xylophis', money = 100, hyperdrive = false, - equipment = { - -- {misc.atmospheric_shielding,1}, - -- {misc.autopilot,1}, - -- {misc.radar,1} + equipment = { + computer_1 = "misc.autopilot", + sensor = "sensor.radar", + hull_mod = "hull.atmospheric_shielding", + thruster = "thruster.default_s1", }, cargo = { { Commodities.hydrogen, 2 } @@ -158,48 +152,57 @@ local function startGame(gameParams) player:Enroll(member) end - local eqSections = { - engine = 'hyperspace', - laser_rear = 'laser', - laser_front = 'laser' - } - - if not equipment2 then - - -- TODO: old equipment API no longer supported - - else - local equipSet = player:GetComponent("EquipSet") player:UpdateEquipStats() - for _, item in ipairs(equipment2) do + -- slotless + for _, item in ipairs(gameParams.ship.equipment) do local proto = Equipment.Get(item) - if not equipSet:Install(proto()) then - print("Couldn't install equipment item {} in misc. cargo space" % { proto:GetName() }) + if not equipSet:Install(proto:Instance()) then + logWarning("Couldn't install equipment item {} in misc. cargo space" % { proto:GetName() }) end end - for slot, item in pairs(equipment2) do - local proto = Equipment.Get(item) - -- print("Installing equipment {} (proto: {}) into slot {}" % { item, proto, slot }) - if type(slot) == "string" then - local slotHandle = equipSet:GetSlotHandle(slot) - if slotHandle then - local inst = proto:Instance() - - if slotHandle.count then - inst:SetCount(slotHandle.count) + local function installEquipment(nodes, prefix) + for slot, node in pairs(nodes) do + + local item + + if type(node) == 'table' then + item = node.id + assert(item) + else + item = node + end + + if prefix then + slot = prefix .. slot + end + + local proto = Equipment.Get(item) + if type(slot) == "string" then + local slotHandle = equipSet:GetSlotHandle(slot) + if slotHandle then + local inst = proto:Instance() + + if inst.SpecializeForShip then inst:SpecializeForShip(equipSet.config) end + + if slotHandle.count then + inst:SetCount(slotHandle.count) + end + + if not equipSet:Install(inst, slotHandle) then + logWarning("Couldn't install equipment item {} into slot {}" % { inst:GetName(), slot }) + end end - if not equipSet:Install(inst, slotHandle) then - print("Couldn't install equipment item {} into slot {}" % { inst:GetName(), slot }) + if type(node) == 'table' then + installEquipment(node.slots, slot .. "##") end end end end - - end + installEquipment(gameParams.ship.equipment) ---@type CargoManager local cargoMgr = player:GetComponent('CargoManager') @@ -287,6 +290,11 @@ local function hasNameInArray(param, array) end end +local function unlockAll() + Layout.setLock(false) + FlightLogParam.value.Custom = {{ entry = "Custom start of the game - for the purpose of debugging or cheat." }} +end + -- wait a few frames, and then calculate the static layout (updateLayout) local initFrames = 2 @@ -305,8 +313,7 @@ NewGameWindow = ModalWindow.New("New Game", function() profileCombo.selected = ret local action = profileCombo.actions[ret + 1] if action == 'DO_UNLOCK' then - Layout.setLock(false) - FlightLogParam.value.Custom = {{ text = "Custom start of the game - for the purpose of debugging or cheat." }} + unlockAll() else setStartVariant(StartVariants.item(ret + 1)) end @@ -423,7 +430,7 @@ function NewGameWindow:open() end if self.debugMode then profileCombo.selected = #profileCombo.items - 1 - Layout.setLock(false) + unlockAll() end ModalWindow.open(self) end diff --git a/data/pigui/modules/new-game-window/game-param.lua b/data/pigui/modules/new-game-window/game-param.lua index 575c31e7c0..dbea4930a5 100644 --- a/data/pigui/modules/new-game-window/game-param.lua +++ b/data/pigui/modules/new-game-window/game-param.lua @@ -48,8 +48,8 @@ GameParam.reader = Helpers.versioned {{ ---@return string? errorString function GameParam:fromSaveGame(saveGame) local value, errorString = self.reader(saveGame) + if value then self.value = value end if errorString then return errorString end - self.value = value end function GameParam:isEmpty() diff --git a/data/pigui/modules/new-game-window/ship.lua b/data/pigui/modules/new-game-window/ship.lua index 591da3c1e0..0abe5070ab 100644 --- a/data/pigui/modules/new-game-window/ship.lua +++ b/data/pigui/modules/new-game-window/ship.lua @@ -6,13 +6,17 @@ local Lang = require 'Lang' local leq = Lang.GetResource("equipment-core") local lc = Lang.GetResource("core") local lui = Lang.GetResource("ui-core") +local msgbox = require 'pigui.libs.message-box' local utils = require 'utils' local ShipDef = require 'ShipDef' local ShipObject = require 'Ship' +local HullConfig = require 'HullConfig' +local ModalWindow = require 'pigui.libs.modal-win' local ModelSpinner = require 'PiGui.Modules.ModelSpinner' local ModelSkin = require 'SceneGraph.ModelSkin' local Commodities = require 'Commodities' local Equipment = require 'Equipment' +local EquipSet = require 'EquipSet' local Engine = require 'Engine' local ShipNames = require 'pigui.modules.new-game-window.ship-names' local textTable = require 'pigui.libs.text-table' @@ -54,10 +58,8 @@ function ShipType:draw() Widgets.alignLabel(lui.SHIP_TYPE, ShipType.layout, function() local changed, ret = Widgets.combo(self.lock, "##shipNames", self.selected, self.shipNames) if changed then - self.selected = ret - self.value = self.shipIDs[ret + 1] ShipModel.value.pattern = 1 - ShipModel:updateModel() + self:setShipID(self.shipIDs[ret + 1]) end end) end @@ -65,8 +67,9 @@ end function ShipType:setShipID(shipID) local index = utils.indexOf(ShipType.shipIDs, shipID) assert(index, "unknown ship ID: " .. tostring(shipID)) - ShipType.value = shipID + self.value = shipID self.selected = index - 1 + self.updated() end function ShipType:fromStartVariant(variant) @@ -333,7 +336,7 @@ function ShipCargo:countCommodity(name) end function ShipCargo:draw() - if not ui.collapsingHeader(lui.CARGO, { "DefaultOpen" }) then return end + Widgets.filledHeader(lui.CARGO, ShipType.layout.width) -- Indent the table slightly ui.addCursorPos(Vector2(ui.getItemSpacing().x, 0)) @@ -425,461 +428,1303 @@ ShipCargo.reader = Helpers.versioned {{ -- -- ship equipment -- --- value: see below --- local ShipEquip = GameParam.New(lui.EQUIPMENT, "ship.equipment") --- by default, only _one_ unit can be put on a slot with a given id, i.e. 'ecm', 'atmo_shield' --- often one EquipmentType has a slot of the same name for itself, i.e hull_autorepair has slot hull_autorepair +-- tree +-- string (slot id) -> string (installed equipment id), +-- or string (slot id) -> { id: string (installed equipment id), slots: } +-- id can be nil, if slot is empty, but there are equipment on sub slots +-- slotless equipment is at the top level, as an array +ShipEquip.value = {} --- special slot classes: +-- tree +-- string (slot id) -> { object: HullConfig.Slot, orphaned: bool, installed: equipID, children: } +-- +-- ship slots merged with slots from value +-- does not contain slotless equipment +ShipEquip.slots = {} + +-- array, actually a list of the ship's equipment, ready to be displayed +-- item: { +-- slotName: string (pretty slot name) +-- itemName: string (pretty equip name) +-- itemID: string (equipment id) +-- mass: number +-- volume: number +-- errorString: string, if not nil, then the equipment is installed incorrectly +-- path: array of string, a chain of names of slots and subslots, starting +-- with the parent on which the equipment is located, widely used. +-- has special values: {} - root for slotless equipment +-- { } - slotless equipment +-- } +ShipEquip.viewData = {} + +-- centrally store pretty names for equipment +-- table, string (id) -> string (localized name) +ShipEquip.equipNames = {} + +ShipEquip.editDialog = {} + +-- summary info +ShipEquip.summaryList = {} +ShipEquip.mass = 0 +ShipEquip.volume = 0 +ShipEquip.hyperDriveClass = 0 +ShipEquip.thrusterUpgradeLevel = 0 --- it is possible to have several _different_ units on this slot -local sharedSlot = { scoop = true } --- it is possible to have several different or even the _same_ units on this slot -local multiSlot = { missile = true, cabin = true } +ShipEquip.indent = ' ' +ShipEquip.warningSign = '⚠' +-- TODO: localize "S" symbol? +ShipEquip.sizeLetter = 'S' --- also very special slots, they have separate lists in Equipment - 'hyperspace' and 'laser' --- sections of the same name are created for them in self.value -local hardSlots = { 'engine', 'laser_front', 'laser_rear' } +function ShipEquip:removeItem(path) --- also hide these IDs, they are a strange implementation detail -local hiddenIDs = { cabin_occupied = true } + local value = self.value + for i = 1, #path - 1, 1 do + value = value[path[i]].slots + end --- all the equipment, sorted -ShipEquip.lists = { - hyperspace = {}, - laser = {}, - misc = {} -} --- fill and sort equipment lists -for slot, tbl in pairs(ShipEquip.lists) do - -- FIXME: convert to use new equipment API - -- for k, _ in pairs(Equipment[slot]) do - -- if not hiddenIDs[k] then - -- table.insert(tbl, k) - -- end - -- end -end - -for _, v in pairs(ShipEquip.lists) do - table.sort(v) -end - --- we have 4 sections, the first 3 correspond to "special hard slots", and the fourth 'misc' - to all the others - --- They are listed in the equipment list of the same name 'misc' -ShipEquip.sections = { - engine = { list = ShipEquip.lists.hyperspace, label = leq.PROPULSION, }, - laser_front = { list = ShipEquip.lists.laser, label = lui.FRONT_WEAPON, }, - laser_rear = { list = ShipEquip.lists.laser, label = lui.REAR_WEAPON, }, - misc = { list = ShipEquip.lists.misc, label = lc.MISCELLANEOUS, } -} + local key = path[#path] -ShipEquip.combos = { - engine = { l7d_list = nil, selected = 0 }, - laser_front = { l7d_list = nil, selected = 0 }, - laser_rear = { l7d_list = nil, selected = 0 }, - misc = { l7d_list = nil, list = nil } -} + if type(key) == 'number' then + table.remove(value, key) + else + value[key] = nil + end +end --- values is the names of the equipment in the equipment table --- 'misc' is an array, because we draw this list on the screen -ShipEquip.value = { - engine = nil, - laser_front = nil, - laser_rear = nil, - misc = {} -- array of struct { id: equipment_id (string), amount: int } -} +local function isEquipmentSlotless(id) + local eqObject = Equipment.Get(id) + if not eqObject or eqObject.slot then return false end + return true +end + +local function isPathSlotless(path) + if not path then return false end + if #path ~= 1 then return false end + return type(path[1]) == 'number' +end --- utils +function ShipEquip.pathIsEqual(path1, path2) -local function findEquipmentType(eqTypeID) - -- FIXME: convert to new equipment APIs - -- for _, eq_list in pairs({ 'misc', 'laser', 'hyperspace' }) do - -- if Equipment[eq_list][eqTypeID] then - -- return Equipment[eq_list][eqTypeID] - -- end - -- end - -- assert(false, "Wrong Equipment ID: " .. tostring(eqTypeID)) - return nil + if not path1 then return not path2 end + if not path2 then return false end + + if #path1 ~= #path2 then return false end + for i = 1, #path1, 1 do + if path1[i] ~= path2[i] then return false end + end + return true end -local function findEquipmentPath(eqKey) - -- FIXME: convert to new equipment APIs - -- for _, eq_list in pairs({ 'misc', 'laser', 'hyperspace' }) do - -- for id, obj in pairs(Equipment[eq_list]) do - -- if obj.l10n_key == eqKey then - -- return eq_list, id - -- end - -- end - -- end - -- assert(false, "Wrong Equipment ID: " .. tostring(eqKey)) - return nil +-- put node to value +-- create passing nodes if necessary +local function putNode(value, node, path) + + -- slotless + if #path == 0 then + table.insert(value, node) + return + end + + for i = 1, #path - 1, 1 do + local slotID = path[i] + if not value[slotID] then + value[slotID] = { slots = {} } + elseif type(value[slotID]) == 'string' then + value[slotID] = { id = value[slotID], slots = {} } + end + value = value[slotID].slots + end + + local slotID = path[#path] + + value[slotID] = node end -local function hasSlotClass(eqTypeID, slotClass) - -- FIXME: convert to new equipment APIs - -- local eqType = findEquipmentType(eqTypeID) - -- for _, slot in pairs(eqType.slots) do - -- if slotClass[slot] then return true end - -- end - return false +local function safeFormat(value, format) + if value then + if format then return format(value) end + return value + end + return '-' end --- if the slot is already occupied, we return the id of the equipment with which it is occupied -local function checkIfSlotAlreadyOccupied(eqTypeID, list) - local eqType = findEquipmentType(eqTypeID) - -- misc always has one slot - local slot = eqType.slots[1] - for _, entry in pairs(list) do - local checkEqType = findEquipmentType(entry.id) - if checkEqType.slots[1] == slot then return entry.id end +local selectedRow = 0 + +function ShipEquip:draw() + + local h = Widgets.filledHeader(lui.EQUIPMENT, self.layout.width) + local headerEnd = ui.getCursorPos() + + local buttonSize = Vector2(h - Defs.gap.y * 2, h - Defs.gap.y * 2) + local buttonColors = ui.theme.buttonColors.transparent + + ui.setCursorPos(headerEnd + Vector2(self.layout.width - buttonSize.x - Defs.gap.x, Defs.gap.y * 0.5 - h)) + + local selected = self.viewData[selectedRow] + + if not self.lock and ui.iconButton("##equip_edit", ui.theme.icons.pencil, nil, buttonColors, buttonSize) then + ShipEquip:openEditDialog(selected and selected.path) end - return nil + + ui.setCursorPos(headerEnd) + + -- Indent the table slightly + ui.addCursorPos(Vector2(Defs.gap.x, 0)) + + ui.withStyleVars({ CellPadding = Vector2(Defs.gap.x * 2, Defs.gap.y * 0.5) }, function() + + ui.beginTable("equip_table", 4, { "SizingFixedFit", "ScrollY" }, Vector2(0, self.layout.height - h)) + ui.tableSetupScrollFreeze(0, 1) + ui.tableSetupColumn(lc.NAME_OBJECT, { "WidthStretch" }) + ui.tableSetupColumn(leq.SLOT) + ui.tableSetupColumn(leq.STAT_VOLUME) + ui.tableSetupColumn(lc.MASS) + ui.tableHeadersRow() + + for i, v in ipairs(self.viewData) do + ui.tableNextRow() + ui.tableNextColumn() + + if v.errorString then ui.raw.PushStyleColor("Text", ui.theme.colors.alertRed) end + + local indent = Defs.gap.x * 2 + local depth = #v.path - 1 + + if depth > 0 then + ui.addCursorPos(Vector2(depth * indent, 0)) + end + if (ui.selectable(v.itemName .. "##" .. tostring(i), i == selectedRow , { "SpanAllColumns" })) then + selectedRow = i + end + + if v.errorString and ui.isItemHovered() then + ui.withStyleColors({ Text = ui.theme.colors.font }, function() + ui.setTooltip(v.errorString) + end) + end + + ui.tableNextColumn() + if depth > 0 then + ui.addCursorPos(Vector2(depth * indent, 0)) + end + ui.text(v.slotName) + + ui.tableNextColumn() + ui.text(safeFormat(v.volume, ui.Format.Volume)) + + ui.tableNextColumn() + -- add some spaces, because imgui does not do external padding unless you draw borders + ui.text(safeFormat(v.mass, function(x) return ui.Format.Mass(x * 1000, 0) end) .. " ") + + if v.errorString then ui.raw.PopStyleColor(1) end + end + ui.endTable() + end) end -function ShipEquip:getHyperDriveClass() - if not self.value.engine then return 0 end - -- FIXME: convert to new equipment APIs - -- local drive = Equipment.hyperspace[self.value.engine] - -- return drive.capabilities.hyperclass - return 0 +function ShipEquip:valueNodeByPath(path) + + -- slotless root is not node + if #path == 0 then return nil end + + local node = ShipEquip.value + + for _, slot in ipairs(path) do + if not node then return nil end + if node.slots then node = node.slots end + node = node[slot] + end + + return node end -function ShipEquip:getThrusterUpgradeLevel() - -- FIXME: convert to new equipment APIs - -- for _, eq_entry in pairs(self.value.misc) do - -- local eq = Equipment.misc[eq_entry.id] - -- if eq.capabilities.thruster_power then - -- return eq.capabilities.thruster_power - -- end - -- end - return 0 +function ShipEquip:itemIdFromValueNode(node) + if not node then return nil end + if type(node) == 'string' then return node end + if type(node) == 'table' then return node.id end + return nil end -function ShipEquip:setDefaultHyperdrive() - local drive_class = ShipDef[ShipType.value].hyperdriveClass - if drive_class == 0 then - self.value.engine = nil - else - local driveID = "hyperdrive_" .. drive_class - local index = utils.indexOf(self.lists.hyperspace, driveID) - -- FIXME: convert to new equipment API - --assert(index, "unknown drive ID: " .. tostring(driveID)) - self.value.engine = driveID +function ShipEquip:slotNodeByPath(path) + + local i = 1 + local node = ShipEquip.slots + + while i <= #path do + local slot = path[i] + + if not node then return nil end + + if node.children then node = node.children end + + node = node[slot] + + i = i + 1 end - self:update() + return node end -function ShipEquip:removeHyperdrive() - self.value.engine = nil +function ShipEquip:slotObjectByPath(path) + local node = self:slotNodeByPath(path) + return node and node.object end --- adding 1 item, without breaking the alphabetical order -local function addMiscEntry(miscTable, newID) - for i, entry in ipairs(miscTable) do - if newID < entry.id then - table.insert(miscTable, i, { id = newID, amount = 1 }) - return - elseif newID == entry.id then - entry.amount = entry.amount + 1 - return - end - end - table.insert(miscTable, { id = newID, amount = 1 }) +function ShipEquip:canPutItemOnSlot(eqID, path) + + if not eqID or not path then return false end + + if #path == 0 then return isEquipmentSlotless(eqID) end + + local slotObject = self:slotObjectByPath(path) + if not slotObject then return false end + + local eqObject = Equipment.Get(eqID) + if not eqObject then return false end + + return EquipSet.CompatibleWithSlot(eqObject, slotObject) end -local function addToTable(tbl, key, count) - if tbl[key] then - tbl[key] = tbl[key] + count - else - tbl[key] = count +function ShipEquip:viewDataForItem(itemID, path) + + local slotName = self:getPrettySlotName(path) + local errorString + + local warn = '' + local slot = self:slotNodeByPath(path) + if slot and slot.orphaned then + warn = ' ' .. self.warningSign + errorString = leq.INVALID_SLOT end -end --- --- for summary e.t.c. --- array of struct: { eq: EquipmentType, amount: int } --- --- a predictable display order of the list of installed equipment is required --- content is requested every frame, generated only when the equipment changes --- -ShipEquip.summaryList = {} -function ShipEquip:addToSummary(key, count) - local eqlist = self.summaryList - for k, v in pairs(eqlist) do - if v.obj == key then - eqlist[k].count = eqlist[k].count + count - return - end + if not errorString and slot and slot.object and slot.object.required and not ShipEquip:itemByPath(path) then + errorString = leq.REQUIRED_SLOT_IS_EMPTY end - table.insert(eqlist, { obj = key, count = count }) + + if not errorString and not itemID then + errorString = leq.EMPTY_EQUIPMENT_PROVIDING_SLOTS + end + + if not errorString and not self:canPutItemOnSlot(itemID, path) and not isEquipmentSlotless(itemID) and not isPathSlotless(path) then + errorString = leq.EQUIPMENT_DOES_NOT_FIT_INTO_THE_SLOT + end + + -- top-level slotless equipment + if type(slotName) == 'number' and #path == 1 then slotName = '' end + + local p = self:getEquipParams(itemID) + + return { + slotName = slotName .. (warn or ''), + itemName = p.name, mass = p.mass, volume = p.volume, path = path, itemID = itemID, + errorString = errorString + } end --- generate static localized equipment lists for 'hard slot' combos -for _, section_id in pairs(hardSlots) do - local combo = ShipEquip.combos[section_id] - local section = ShipEquip.sections[section_id] - combo.l7d_list = { lui.NO } - for _, id in ipairs(section.list) do - table.insert(combo.l7d_list, leq[findEquipmentType(id).l10n_key]) +-- slot can be number or string +-- strings go before the numbers so that slotless things come at the end +local function slotLess(x1, x2) + if type(x1) ~= type(x2) then return type(x1) == 'string' + else return x1 < x2 end end --- bring everything into a consistent state based on the (most likely) updated value -function ShipEquip:update() +function ShipEquip.itemLess(x1, x2) + return slotLess(x1.path[#x1.path], x2.path[#x2.path]) +end + +function ShipEquip:itemByPath(path) + + local node = self:valueNodeByPath(path) + + if not node then return nil end + if type(node) == 'table' then return node.id end + return node +end + +function ShipEquip.removeNotProvidedSlots(itemID, slots) + + local obj = Equipment.Get(itemID) - -- update combos - for _, section in ipairs(hardSlots) do - local eqSection = self.sections[section] - local eqID = self.value[section] - self.combos[section].selected = eqID and utils.indexOf(eqSection.list, eqID) or 0 + if not obj then return end + + if not obj.provides_slots then + for k, _ in pairs(slots) do + slots[k] = nil + end + return end - local misc_combo = self.combos.misc - local selectedIDs = {} - for _, entry in ipairs(self.value.misc) do - selectedIDs[entry.id] = true + + local providedSlots = {} + for _, slot in pairs(obj.provides_slots) do + providedSlots[slot.id] = true end - -- in a misc_combo, only those elements that are not in the list - misc_combo.list = removeElems(self.lists.misc, selectedIDs) - misc_combo.selected = 0 - misc_combo.l7d_list = {"+"} - for _, id in ipairs(misc_combo.list) do - table.insert(misc_combo.l7d_list, leq[findEquipmentType(id).l10n_key]) + + for k, _ in pairs(slots) do + if not providedSlots[k] then + slots[k] = nil + end end +end - -- update stats and summary - self.usedSlots = {} -- many slots do not allow more than one unit - self.mass = 0 -- equipment mass - self.summaryList = {} +function ShipEquip:isValid() + return self.valid and ShipSummary.equip.valid +end - for _, section_id in pairs(hardSlots) do - local eqID = self.value[section_id] - if eqID then - local eqType = findEquipmentType(eqID) - local slot = section_id - addToTable(self.usedSlots, slot, 1) - self:addToSummary(eqType, 1) - --self.mass = self.mass + eqType.capabilities.mass - end +ShipEquip.slotPrettyNamesCache = {} +function ShipEquip:tryCachedPrettySlotName(slotID) + + if not slotID then return nil end + + local cached = self.slotPrettyNamesCache[slotID] + if cached then + return cached + else + return slotID + end +end + +function ShipEquip:cachePrettySlotName(slotID, name) + + assert(slotID) + + self.slotPrettyNamesCache[slotID] = name + return name +end + +function ShipEquip:getPrettySlotName(path) + + assert(path) + + local slotID = path[#path] + + local slot = self:slotNodeByPath(path) + if not slot then return self:tryCachedPrettySlotName(slotID) end + + assert(slot.prettyName) + + return slot.prettyName +end + +function ShipEquip:createPrettySlotName(slotID, slot) + + assert(slotID) + if not slot then return self:tryCachedPrettySlotName(slotID) end + + -- will not show S1 + local size = '' + if slot.size and slot.size > 1 then + size = ' ' .. self.sizeLetter .. tostring(slot.size) end - for _, entry in ipairs(self.value.misc) do - local eqType = findEquipmentType(entry.id) - local slot = eqType.slots[1] -- misc always has one slot - addToTable(self.usedSlots, slot, entry.amount) - self:addToSummary(eqType, entry.amount) - --self.mass = self.mass + eqType.capabilities.mass * entry.amount + if slot.i18n_key then + return self:cachePrettySlotName(slot.id, Lang.GetResource(slot.i18n_res)[slot.i18n_key] .. size) end + + local base_type = slot.type:match("([%w_-]+)%.?") + local i18n_key = (slot.hardpoint and "HARDPOINT_" or "SLOT_") .. base_type:upper() + return self:cachePrettySlotName(slot.id, leq[i18n_key] .. size) end -ShipEquip:update() +function ShipEquip:getPrettyItemName(itemID) + return self.equipNames[itemID] or itemID or lc.UNKNOWN +end -function ShipEquip:draw() - if not ui.collapsingHeader(lui.EQUIPMENT, { "DefaultOpen" }) then return end +function ShipEquip:getEquipParams(itemID) - -- hard slots + if not itemID then return { name = leq.EMPTY_SLOT } end - local allWidth = layout.rightWidth - Defs.scrollWidth - local spacing = Defs.gap.x + local itemName = self:getPrettyItemName(itemID) - local combosWidth = allWidth - layout.sectionWidth - Defs.eqTonnesWidth - spacing * 3 - ui.columns(3, "#equip-oneliners") - ui.setColumnWidth(0, layout.sectionWidth + spacing) - ui.setColumnWidth(1, combosWidth + spacing) - ui.setColumnWidth(2, Defs.eqTonnesWidth + spacing) - for _, section_id in ipairs(hardSlots) do - local section = self.sections[section_id] - local combo = self.combos[section_id] - ui.alignTextToFramePadding() - ui.text(section.label) - layout.sectionWidth = math.max(ui.calcTextSize(section.label).x, layout.sectionWidth) - ui.nextColumn() - ui.nextItemWidth(combosWidth) - local changed, ret = Widgets.combo(self.lock, "##equip_hardslot_" .. section_id, combo.selected, combo.l7d_list) - if changed then - self.value[section_id] = section.list[ret] - self:update() + local obj = Equipment.Get(itemID) + if not obj then return { name = itemName } end + + return { name = itemName, mass = obj.mass or 0.0, volume = obj.volume or 0.0 } +end + +-- return path or nil +function ShipEquip:findSlotForEquipment(eqID, dir) + + local slots + if not dir then + if isEquipmentSlotless(eqID) then return {} end + dir = {} + slots = self.slots + else + slots = self:slotNodeByPath(dir).children + end + + local subNodes = {} + + for slot, node in pairs(slots) do + local path = table.copy(dir) + table.insert(path, slot) + if self:canPutItemOnSlot(eqID, path) and not self:itemByPath(path) then + return path end - ui.nextColumn() - if combo.selected ~= 0 then - local eqID = section.list[combo.selected] - local mass = findEquipmentType(eqID).capabilities.mass - ui.alignTextToFramePadding() - ui.text(tostring(mass)..'t') + if node.children then + local item = self:itemByPath(path) + if item and self:canPutItemOnSlot(item, path) then + table.insert(subNodes, path) + end end - ui.nextColumn() end - ui.columns(1) + for _, path in ipairs(subNodes) do + local goodPath = self:findSlotForEquipment(eqID, path) + if goodPath then return goodPath end + end +end - ui.dummy(ui.getItemSpacing()) +function ShipEquip:tryFixOneBadSlot(dir, assumeBad) - -- misc slots + local value + if not dir then + dir = {} + value = self.value + else + value = self:valueNodeByPath(dir).slots + end - local section = self.sections.misc - ui.text(section.label .. ":") + for slotID, node in pairs(value) do - -- Indent the table slightly - ui.addCursorPos(Vector2(ui.getItemSpacing().x, 0)) + local path = table.copy(dir) + table.insert(path, slotID) - ui.beginTable("misc_equip_table", 4, { "SizingFixedFit" }) - ui.tableSetupColumn("label", { "WidthStretch" }) - ui.tableSetupColumn("quantity") - ui.tableSetupColumn("mass", nil, Defs.eqTonnesWidth) + local eqID = type(node) == 'table' and node.id or node - for i, v in ipairs(self.value.misc) do - ui.tableNextRow() - ui.tableNextColumn() + local slotlessOK = eqID and isPathSlotless(path) and isEquipmentSlotless(eqID) + local goodEquipOnBadPlace = eqID and Equipment.Get(eqID) and (not self:canPutItemOnSlot(eqID, path) and not slotlessOK or assumeBad) - local eqType = findEquipmentType(v.id) - ui.alignTextToFramePadding() - ui.text(leq[eqType.l10n_key]) - ui.tableNextColumn() - if hasSlotClass(v.id, multiSlot) then - ui.nextItemWidth(Defs.dragWidth) - local value, changed = Widgets.incrementDrag(self.lock, "##eqdrag"..v.id, v.amount, 1, 1, 1000000, "x %.0f") - if changed then - v.amount = math.round(value) - self:update() + local assumeBadChildren = assumeBad + if goodEquipOnBadPlace then + local goodPath = self:findSlotForEquipment(eqID) + if goodPath then + putNode(self.value, node, goodPath) + self:removeItem(path) + return true end + assumeBadChildren = true end - ui.tableNextColumn() - ui.alignTextToFramePadding() - ui.text(tostring(eqType.capabilities.mass * v.amount)..'t') - ui.tableNextColumn() - if not self.lock and ui.iconButton("##eqremove" .. v.id, ui.theme.icons.cross, nil, nil, Vector2(Defs.removeWidth, Defs.removeWidth)) then - table.remove(self.value.misc, i) - self:update() + + if type(node) == 'table' then + if self:tryFixOneBadSlot(path, assumeBadChildren) then return true end end - ui.tableNextColumn() end +end - ui.endTable() +local function checkNonExistentEquipment(value) - if not self.lock then - local combo = self.combos.misc - ui.nextItemWidth(Defs.addWidth) - local changed, ret = ui.combo("##addequip", 0, combo.l7d_list) - if changed and ret > 0 then - local newID = combo.list[ret] - local occupied - if not (hasSlotClass(newID, multiSlot) or hasSlotClass(newID, sharedSlot)) then - occupied = checkIfSlotAlreadyOccupied(newID, self.value.misc) - if occupied then - local popup = Widgets.ConfirmPopup() - popup.drawQuestion = function() - ui.text("Replace " .. leq[findEquipmentType(occupied).l10n_key]) - ui.text("with " .. leq[findEquipmentType(newID).l10n_key] .. "?") - end - popup.yesAction = function() - for i, entry in ipairs(self.value.misc) do - if entry.id == occupied then - self.value.misc[i] = { id = newID, amount = 1 } - ShipEquip:update() - break; - end - end - end - popup:open() - end - end - if not occupied then - addMiscEntry(self.value.misc, newID) - self:update() + local function checkID(itemID) + local obj = Equipment.Get(itemID) + assert(obj, "Non existent equipment: " .. tostring(itemID)) + end + + for _, node in pairs(value) do + if type(node) == 'table' then + -- id is allowed to be empty, there may be subslots + if node.id then + checkID(node.id) end + elseif type(node) == 'string' then + checkID(node) + else + assert(false, "Unexpected node type in the equipment tree") end end end function ShipEquip:fromStartVariant(variant) - if variant.hyperdrive then - self:setDefaultHyperdrive() - else - self:removeHyperdrive() - end - - local eq_value = self.value - eq_value.laser_front = nil - eq_value.laser_rear = nil - eq_value.misc = {} - for _, entry in pairs(variant.equipment) do - local eq, amount = table.unpack(entry) - local eq_list, id = findEquipmentPath(eq.l10n_key) - if eq_list == 'misc' then - addMiscEntry(eq_value.misc, id) - elseif eq_list == 'laser' then - for _ = 1, amount do - if not eq_value.laser_front then - eq_value.laser_front = id - elseif not eq_value.laser_rear then - eq_value.laser_rear = id - else - assert(false, "Too many lasers in start variant!") + + self.value = table.deepcopy(variant.equipment) + checkNonExistentEquipment(self.value) + self.lock = true +end + +function ShipEquip:cleanupValue(value) + for k, v in pairs(value) do + if type(v) == 'table' then + if v.slots then + + if v.id then + self.removeNotProvidedSlots(v.id, v.slots) + end + + if utils.count(v.slots) ~= 0 then + self:cleanupValue(v.slots) + end + + if utils.count(v.slots) == 0 then + -- v.id can be nil, if it had subslots, bu it itself was deleted earlier + -- now there are no subslots and it will be deleted completely + value[k] = v.id end + else + assert(false, 'item is table, but has no "slots" field') end - else - assert(false, "unacceptable eq_list: " .. tostring(eq_list)) end end - self:update() - self.lock = true end -function ShipEquip:isValid() - ShipSummary:prepareAndValidateParamList() - return ShipSummary.equip.valid -end +function ShipEquip:mergeSlots(slots, value) ----@param unitBase table where to find the equipment unit ----@param unitPath string ----@param eqTable table with IDs, can be subtable ----@param eqTableSectionPath string ----@return string? id ----@return string? errorString -local function findSavedEquipmentID(unitBase, unitPath, eqTable, eqTableSectionPath) - local entry, errorString = Helpers.getByPath(unitBase, unitPath) - -- missing entry is acceptable - if errorString then return nil end - if entry then - local eqSection - eqSection, errorString = Helpers.getByPath(eqTable, eqTableSectionPath) - if errorString then return nil, errorString end - assert(eqSection) - for id, eq in pairs(eqSection) do - if eq == entry then return id end + local nodes = {} + + if slots then + for k, v in pairs(slots) do + nodes[k] = { object = v } + end + end + + if value then -- merge empty and orphaned slots + for k, _ in pairs(value) do + -- don't merge numbered (slotless) equipment + if not nodes[k] and type(k) ~= 'number' then + nodes[k] = { orphaned = true } + end + end + end + + if utils.count(nodes) == 0 then return end + + for id, node in pairs(nodes) do + local eq = value and value[id] + local eqName = eq + local eqSlots + if eq and type(eq) ~= 'string' then + eqName = eq.id + eqSlots = eq.slots + end + if eqName then + local obj = Equipment.Get(eqName) + if obj and obj.provides_slots then + local slotTable = {} + for _, slot in pairs(obj.provides_slots) do + slotTable[slot.id] = slot + end + + node.children = self:mergeSlots(slotTable, eqSlots) + end + node.installed = eqName + elseif eqSlots then + node.children = self:mergeSlots(nil, eqSlots) end + node.prettyName = self:createPrettySlotName(id, node.object) end + return nodes end -ShipEquip.reader = Helpers.versioned {{ - version = 89, - fnc = function(saveGame) - -- a table with all possible equipment is located directly in the saveGame - -- The ship's equipment set stores refs to this table items, and the - -- string IDs of the equipment are only in this table - local eqTable, errorString = Helpers.getByPath(saveGame, "lua_modules_json/Equipment") - if errorString then return nil, errorString end - assert(eqTable) +function ShipEquip:update() - local eqTableMisc - eqTableMisc, errorString = Helpers.getByPath(eqTable, "misc") - if errorString then return nil, errorString end - assert(eqTableMisc) + local repCount = 0 + repeat + self:cleanupValue(self.value) + self.slots = self:mergeSlots(HullConfig.GetHullConfigs()[ShipType.value].slots, self.value) - -- table with slots - local eqSet - eqSet, errorString = Helpers.getPlayerShipParameter(saveGame, "ship/equipSet/lua_ref_json/slots") - if errorString then return nil, errorString end + repCount = repCount + 1 + assert(repCount < 1000, "probably endless loop") - local engine, laser_front, laser_rear + until not self:tryFixOneBadSlot() - local misc = {} - for slotName, slot in pairs(eqSet) do - if slotName == 'engine' then - engine, errorString = findSavedEquipmentID(slot, "#1", eqTable, "hyperspace") - elseif slotName == 'laser_front' then - laser_front, errorString = findSavedEquipmentID(slot, "#1", eqTable, "laser") - elseif slotName == 'laser_rear' then + local function createViewData(result, slots, dir) + + if not slots then return end + + local sorted = {} + for id, node in pairs(slots) do + if node.object and node.object.required or node.installed then + table.insert(sorted, id) + end + end + table.sort(sorted, slotLess) + + for _, slot in ipairs(sorted) do + + local eq = slots[slot] + + local path = table.copy(dir) + table.insert(path, slot) + + table.insert(result, self:viewDataForItem(eq.installed, path)) + + if eq.children then + createViewData(result, eq.children, path) + end + end + end + self.viewData = {} + createViewData(self.viewData, self.slots, {}) + + for i, slotless in ipairs(self.value) do + table.insert(self.viewData, self:viewDataForItem(slotless, { i })) + end + + local sameName = {} + for id, eq in pairs(Equipment.new) do + + local localized + + local l = Lang.GetResource(eq.l10n_resource) + localized = l[eq.l10n_key] or id + + if sameName[localized] then + table.insert(sameName[localized], id) + else + sameName[localized] = { id } + end + end + + self.equipNames = {} + for localized, ids in pairs(sameName) do + + -- assume that only equipment differing in size may have an identical name + if #ids > 1 then + for _, id in ipairs(ids) do + local slot = Equipment.new[id].slot + local size = "?" + if not slot then + logWarning("Equipment with a non-unique localized name has no slot: " .. id) + elseif not slot.size then + logWarning("Equipment with a non-unique localized name has no size: " .. id) + else + size = self.sizeLetter .. tostring(slot.size) + end + + self.equipNames[id] = localized .. " " .. size + end + else + self.equipNames[ids[1]] = localized + end + end + + self.editDialog:updateData(self) + + local function addToList(list, obj, count) + for k, v in pairs(list) do + if v.obj == obj then + list[k].count = list[k].count + count + return + end + end + table.insert(list, { obj = obj, count = count }) + end + + self.hyperDriveClass = 0 + self.thrusterUpgradeLevel = 0 + self.summaryList = {} + self.valid = true + self.volume = 0 + self.mass = 0 + + for _, item in ipairs(self.viewData) do + if item.errorString then + self.valid = false + end + if item.volume then + self.volume = self.volume + item.volume + end + if item.mass then + self.mass = self.mass + item.mass + end + + local eq = Equipment.Get(item.itemID) + local slot = self:slotObjectByPath(item.path) + local isThruster = slot and slot.type and EquipSet.SlotTypeMatches(slot.type, "thruster") + local isHyperDrive = slot and slot.type and EquipSet.SlotTypeMatches(slot.type, "hyperdrive") + + if isThruster then + self.thrusterUpgradeLevel = eq and eq.capabilities and eq.capabilities.thruster_power or 0 + elseif isHyperDrive then + self.hyperDriveClass = eq and eq.slot and eq.slot.size or 0 + elseif eq then + addToList(self.summaryList, eq, 1) + end + end + + ShipSummary:prepareAndValidateParamList() +end + +ShipEquip.editDialog = ModalWindow.New('ShipEquipEditDialog', function(self) + + local listWidth = self.layout.width / 2 + + ui.text(self.name) + ui.text("") + + local childHeight = self.layout.height - ui.getCursorPos().y + + ui.child('ShipEquipEditDialogLeft', Vector2(listWidth, childHeight), function() + + ui.alignTextToFramePadding() + ui.text(self.leftPanel.label) + ui.sameLine() + + -- https://github.com/ocornut/imgui/issues/455 + if ui.isWindowFocused("RootAndChildWindows") and not ui.isAnyItemActive() and not ui.isMouseClicked(0) and self:topmost() then + ui.setKeyboardFocusHere(0) + end + local filter, entered = ui.inputText("##EditDialogListFilter", self.itemFilter) + if entered then + self.itemFilter = filter + self.leftPanel:masterFilter(self, true) + end + + ui.separator() + + ui.child('ShipEquipEditDialogLeftList', ui.getContentRegion(), function() + local changed = self.leftPanel:show(self) + if changed then + self.rightPanel:slaveFilter(self) + end + end) + end) + + ui.sameLine() + + ui.child('ShipEquipEditDialogRight', Vector2(listWidth, childHeight), function() + + ui.alignTextToFramePadding() + ui.text(self.rightPanel.label) + + ui.separator() + + ui.child('ShipEquipEditDialogRightList', ui.getContentRegion(), function() + self.rightPanel:show(self) + end) + end) + + local function property(key, value, format) + ui.withStyleColors({Text = ui.theme.colors.fontDark}, function() + ui.text(key) + end) + ui.sameLine() + ui.text(safeFormat(value, format)) + end + + ui.separator() + + if self.selectedItem then + property(leq.STAT_VOLUME, self.selectedItemProperties.volume, ui.Format.Volume) + ui.sameLine() + property(lc.MASS, self.selectedItemProperties.mass, function(v) return ui.Format.Mass(v * 1000, 0) end) + else + ui.text("") + end + + if ui.button(self.rightPanel.switchLabel) then + + local temp = self.rightPanel + self.rightPanel = self.leftPanel + self.leftPanel = temp + self:updateViews(true) + end + + ui.sameLine() + + local canInstall = self.selectedSlot and self.selectedItem + local variant = ui.theme.buttonColors + + if ui.button(lui.INSTALL, nil, canInstall and variant.default or variant.disabled) and canInstall then + + local replaced = ShipEquip:itemByPath(self.selectedSlot) + + if self.selectedItem == replaced then + msgbox.OK(leq.THE_SAME_EQUIPMENT_HAS_BEEN_ALREADY_INSTALLED) + elseif replaced then + msgbox.OK_CANCEL(leq.SLOT_IS_NOT_EMPTY_REPLACE_THE_EQUIPMENT_QUESTION, function() + putNode(ShipEquip.value, self.selectedItem, self.selectedSlot) + ShipEquip:update() + self:updateViews() + end) + else + putNode(ShipEquip.value, self.selectedItem, self.selectedSlot) + ShipEquip:update() + self:updateViews() + end + end + + ui.sameLine() + + local valueNode = self.selectedSlot and ShipEquip:valueNodeByPath(self.selectedSlot) + + local canDelete = valueNode + + if ui.button(lui.DELETE, nil, canDelete and variant.default or variant.disabled) and canDelete then + ShipEquip:removeItem(self.selectedSlot) + ShipEquip:update() + self:updateViews() + end + + ui.sameLine() + + local canCut = valueNode + + if ui.button(lui.CUT, nil, canCut and variant.default or variant.disabled) and canCut then + self.clipBoard = valueNode + ShipEquip:removeItem(self.selectedSlot) + ShipEquip:update() + self:updateViews() + end + + ui.sameLine() + + local canCopy = valueNode + if ui.button(lui.COPY, nil, canCopy and variant.default or variant.disabled) and canCopy then + self.clipBoard = valueNode + end + + ui.sameLine() + + local canPaste + if self.clipBoard and self.selectedSlot then + local sourceID = ShipEquip:itemIdFromValueNode(self.clipBoard) + if sourceID then + canPaste = ShipEquip:canPutItemOnSlot(sourceID, self.selectedSlot) + end + end + if ui.button(lui.PASTE, nil, canPaste and variant.default or variant.disabled) and canPaste then + local target = ShipEquip:valueNodeByPath(self.selectedSlot) + if target then + msgbox.YES_NO(leq.TARGET_SLOT_IS_NOT_EMPTY_PUT_THE_REPLACED_ONE_IN_THE_CLIPBOARD_QUESTION, { + yes = function() + self.clipBoard = target + end + }) + end + local copied = type(self.clipBoard) == 'table' and table.deepcopy(self.clipBoard) or self.clipBoard + putNode(ShipEquip.value, copied, self.selectedSlot) + ShipEquip:update() + self:updateViews() + end + + ui.sameLine() + + if ui.button(lui.CLOSE) then + self:close() + end +end, +function (_, drawPopupFn) + ui.setNextWindowPosCenter('Always') + ui.withFont(Defs.mainFont, drawPopupFn) +end) + +function ShipEquip.editDialog:updateData(host) + + self.items = {} + for id, localized in pairs(host.equipNames) do + if host:compatibleWithShip(id, host.slots) then + table.insert(self.items, { id = id, localized = localized }) + end + end + + table.sort(self.items, function(a, b) return a.localized < b.localized end) + + local function flatSlots(result, slots, dir) + + if not slots then return end + + local sorted = {} + for k, _ in pairs(slots) do + table.insert(sorted, k) + end + table.sort(sorted, slotLess) + + for _, slot in ipairs(sorted) do + + local path = table.copy(dir) + table.insert(path, slot) + + table.insert(result, { path = path, view = self.generateRow(path) }) + if slots[slot].children then + flatSlots(result, slots[slot].children, path) + end + end + + return result + end + self.slots = {} + flatSlots(self.slots, host.slots, {}) + + table.insert(self.slots, { path = {}, view = leq.SLOTLESS }) + + -- add slotless equipment from value to the slots + for i, id in ipairs(host.value) do + table.insert(self.slots, { path = { i }, grayed = true, view = ' ← ' .. host:getPrettyItemName(id) }) + end +end + +ShipEquip.editDialog.itemPanel = { label = leq.ITEM, switchLabel = leq.BY_ITEM } +ShipEquip.editDialog.slotPanel = { label = leq.SLOT, switchLabel = leq.BY_SLOT } + +function ShipEquip.editDialog.itemPanel:show(dialog) + + local changed = false + + if #dialog.itemsFiltered == 0 then + ui.text(leq.NO_SUITABLE_EQUIPMENT) + return false + end + + for i, item in ipairs(dialog.itemsFiltered) do + + local selected = dialog.selectedItem and dialog.selectedItem == item.id + + if ui.selectable(item.localized .. "##" .. tostring(i), selected) then + dialog.selectedItem = item.id + dialog.selectedItemProperties = ShipEquip:getEquipParams(item.id) + changed = true + end + + if selected and self.justFiltered then + self.justFiltered = false + ui.setScrollHereY() + end + end + + return changed +end + +-- so that we can faster compare paths using operator "=" for references +function ShipEquip.editDialog.slotPanel.fixupPaths(dialog) + + if dialog.selectedSlot then + for _, slot in ipairs(dialog.slots) do + if ShipEquip.pathIsEqual(dialog.selectedSlot, slot.path) then + dialog.selectedSlot = slot.path + break + end + end + end +end + +function ShipEquip.editDialog.slotPanel:show(dialog) + + if #dialog.slotsFiltered == 0 then + ui.text(leq.NO_SUITABLE_SLOTS) + return false + end + + local changed = false + + for i, slot in ipairs(dialog.slotsFiltered) do + + local grayed = slot.grayed + if grayed then ui.raw.PushStyleColor("Text", ui.theme.colors.fontDark) end + + local selected = dialog.selectedSlot and dialog.selectedSlot == slot.path + + if ui.selectable(slot.view .. "##" .. tostring(i), selected) and not slot.grayed then + dialog.selectedSlot = slot.path + changed = true + end + + if selected and self.justFiltered then + self.justFiltered = false + ui.setScrollHereY() + end + + if grayed then ui.raw.PopStyleColor(1) end + end + + return changed +end + +local function matchesString(name, str) + return string.match(string.lower(name), string.lower(str)) +end + +function ShipEquip:compatibleWithShip(itemID, nodes, dir) + + if not dir then dir = {} end + + for id, node in pairs(nodes) do + + local path = table.copy(dir) + table.insert(path, id) + + if self:canPutItemOnSlot(itemID, path) then return true end + if node.children and self:compatibleWithShip(itemID, node.children, path) then + return true + end + end + + -- slotless on top-level, always compatible + if #dir == 0 and isEquipmentSlotless(itemID) then return true end + + return false +end + +function ShipEquip.editDialog.itemPanel:masterFilter(dialog, moveToCursor) + + self.justFiltered = moveToCursor + + local matchFilter = string.len(dialog.itemFilter) > 0 and dialog.itemFilter + + if not matchFilter then + dialog.itemsFiltered = dialog.items + return + end + + dialog.itemsFiltered = utils.filter_array(dialog.items, function(item) + return matchesString(item.localized, matchFilter) + end) +end + +function ShipEquip.editDialog.itemPanel:slaveFilter(dialog, moveToCursor) + + self.justFiltered = moveToCursor + + if not dialog.selectedSlot then + dialog.itemsFiltered = {} + return + end + + dialog.itemsFiltered = utils.filter_array(dialog.items, function(item) + return ShipEquip:canPutItemOnSlot(item.id, dialog.selectedSlot) + end) + + -- ensure selected is visible in list + if dialog.selectedItem then + for _, item in ipairs(dialog.itemsFiltered) do + if dialog.selectedItem == item.id then return end + end + end + dialog.selectedItem = nil +end + +local function filterSlotsWithParents(slots, passFnc) + + local pass = {} + + for _, slot in ipairs(slots) do + if passFnc(slot) then + pass[slot] = true + end + end + + local function isParent(slot, child) + + if not slot or not slot.path or not child or not child.path then return false end + if #slot.path == 0 then return isPathSlotless(child.path) end + if #child.path == 0 or #child.path <= #slot.path then return false end + + for i = 1, #slot.path, 1 do + if slot.path[i] ~= child.path[i] then return false end + end + return true + end + + local function isParentForSome(slot, childTable) + for child, _ in pairs(childTable) do + if isParent(slot, child) then return true end + end + return false + end + + for _, slot in ipairs(slots) do + if isParentForSome(slot, pass) then + pass[slot] = true + slot.grayed = true + end + end + + return utils.filter_array(slots, function(slot) + return pass[slot] + end) +end + +function ShipEquip.editDialog.slotPanel:masterFilter(dialog, moveToCursor) + + self.justFiltered = moveToCursor + + for _, slot in pairs(dialog.slots) do + slot.grayed = false + end + + local matchFilter = string.len(dialog.itemFilter) > 0 and dialog.itemFilter + if not matchFilter then + dialog.slotsFiltered = dialog.slots + return + end + + dialog.slotsFiltered = filterSlotsWithParents(dialog.slots, function(slot) + return matchesString(slot.view, matchFilter) + end) +end + +function ShipEquip.editDialog.slotPanel:slaveFilter(dialog, moveToCursor) + + self.justFiltered = moveToCursor + + if not dialog.selectedItem then + dialog.slotsFiltered = {} + return + end + + for _, slot in pairs(dialog.slots) do + slot.grayed = false + end + + dialog.slotsFiltered = filterSlotsWithParents(dialog.slots, function(slot) + return ShipEquip:canPutItemOnSlot(dialog.selectedItem, slot.path) + end) + + -- ensure selected is visible in list + if dialog.selectedSlot then + for _, slot in ipairs(dialog.slotsFiltered) do + if dialog.selectedSlot == slot.path then return end + end + end + dialog.selectedSlot = nil +end + +function ShipEquip:openEditDialog(path) + local dialog = self.editDialog + dialog.name = leq.EDIT_EQUIPMENT + dialog.selectedItem = nil + dialog.selectedSlot = path + dialog.clipBoard = nil + dialog.itemFilter = "" + dialog.leftPanel = dialog.slotPanel + dialog.rightPanel = dialog.itemPanel + dialog:updateViews(true) + dialog:open() +end + +function ShipEquip.editDialog:updateViews(moveToCursor) + self.slotPanel.fixupPaths(self) + self.leftPanel:masterFilter(self, moveToCursor) + self.rightPanel:slaveFilter(self, moveToCursor) +end + +function ShipEquip.editDialog.generateRow(path) + + local indent = '' + + for _ = 2, #path, 1 do + indent = indent .. ShipEquip.indent + end + + local slotName = ShipEquip:getPrettySlotName(path) + local slot = ShipEquip:slotNodeByPath(path) + + if slot and slot.orphaned then + slotName = slotName .. ' ' .. ShipEquip.warningSign + end + + local item = ShipEquip:itemByPath(path) + local itemName = '' + if item then + itemName = ShipEquip:getPrettyItemName(item) + if itemName and string.len(itemName) > 0 then + itemName = ' ← ' .. itemName + else + itemName = '' + end + end + return indent .. slotName .. itemName +end + + +local function patch_v90_v91(old) + + local newPrefixes = { + 'hyperspace', 'misc', 'sensor', 'hull', 'laser' + } + + local function tryWithPrefix(oldID) + for _, prefix in ipairs(newPrefixes) do + local prefixed = prefix .. '.' .. oldID + local obj = Equipment.Get(prefixed) + if obj then return prefixed end + end + return oldID + end + + local new = {} + local i = 1 + + for _, section in ipairs({ 'engine', 'laser_front', 'laser_rear' }) do + if old[section] then + new['old_equipment_' .. tostring(i)] = tryWithPrefix(old[section]) + i = i + 1 + end + end + + for _, eqHunk in ipairs(old.misc) do + for _ = 1, eqHunk.amount, 1 do + new['old_equipment_' .. tostring(i)] = tryWithPrefix(eqHunk.id) + i = i + 1 + end + end + + return new +end + +---@param unitBase table where to find the equipment unit +---@param unitPath string +---@param eqTable table with IDs, can be subtable +---@param eqTableSectionPath string +---@return string? id +---@return string? errorString +local function findSavedEquipmentID(unitBase, unitPath, eqTable, eqTableSectionPath) + local entry, errorString = Helpers.getByPath(unitBase, unitPath) + -- missing entry is acceptable + if errorString then return nil end + if entry then + local eqSection + eqSection, errorString = Helpers.getByPath(eqTable, eqTableSectionPath) + if errorString then return nil, errorString end + assert(eqSection) + for id, eq in pairs(eqSection) do + if eq == entry then return id end + end + end +end + +-- adding 1 item, without breaking the alphabetical order +local function addMiscEntry(miscTable, newID) + for i, entry in ipairs(miscTable) do + if newID < entry.id then + table.insert(miscTable, i, { id = newID, amount = 1 }) + return + elseif newID == entry.id then + entry.amount = entry.amount + 1 + return + end + end + table.insert(miscTable, { id = newID, amount = 1 }) +end + +ShipEquip.reader = Helpers.versioned {{ + version = 89, -- valid up to v90 + fnc = function(saveGame) + -- a table with all possible equipment is located directly in the saveGame + -- The ship's equipment set stores refs to this table items, and the + -- string IDs of the equipment are only in this table + local eqTable, errorString = Helpers.getByPath(saveGame, "lua_modules_json/Equipment") + if errorString then return nil, errorString end + assert(eqTable) + + local eqTableMisc + eqTableMisc, errorString = Helpers.getByPath(eqTable, "misc") + if errorString then return nil, errorString end + assert(eqTableMisc) + + -- table with slots + local eqSet + eqSet, errorString = Helpers.getPlayerShipParameter(saveGame, "ship/equipSet/lua_ref_json/slots") + if errorString then return nil, errorString end + + local engine, laser_front, laser_rear + + local misc = {} + for slotName, slot in pairs(eqSet) do + if slotName == 'engine' then + engine, errorString = findSavedEquipmentID(slot, "#1", eqTable, "hyperspace") + elseif slotName == 'laser_front' then + laser_front, errorString = findSavedEquipmentID(slot, "#1", eqTable, "laser") + elseif slotName == 'laser_rear' then laser_rear, errorString = findSavedEquipmentID(slot, "#1", eqTable, "laser") elseif #slot > 0 then for _, unit in ipairs(slot) do @@ -893,13 +1738,54 @@ ShipEquip.reader = Helpers.versioned {{ if errorString then return nil, errorString end end - return { + return patch_v90_v91 { engine = engine, laser_front = laser_front, laser_rear = laser_rear, misc = misc } end +},{ + version = 91, + fnc = function(saveGame) + + local equip, errorString = Helpers.getPlayerShipParameter(saveGame, "lua_components/EquipSet/installed") + if errorString then return nil, errorString end + + local value = {} + local success = true + local sorted = {} + + for slotID, data in pairs(equip) do + + local path = {} + for i in string.gmatch(slotID, "[^#]+") do + table.insert(path, i) + end + + local eqID = data.__proto and data.__proto.id + + if eqID then + table.insert(sorted, { id = eqID, path = path }) + else + success = false + end + end + + -- make sure the parent is added before the child + table.sort(sorted, function(x1, x2) + return #x1.path < #x2.path + end) + + for _, v in ipairs(sorted) do + putNode(value, v.id, v.path) + end + + if not success then + errorString = lui.FAILED_TO_RECOVER_SOME_EQUIPMENT + end + return value, errorString + end }} @@ -917,35 +1803,30 @@ local function greater(x, y) return x > y end -local function rowWithAlert(output, label, current, project, alertIf, unitStr) +local function rowWithAlert(output, label, current, project, alertIf, unitStr, format) if current == nil then current = 0 end if project == nil then project = 0 end if unitStr == nil then unitStr = '' end + if not format then format = '%d' end local color local valid = not alertIf(current, project) if not valid then color = ui.theme.colors.alertRed end output.valid = output.valid and valid - return { label .. ":", string.format("%d / %d" .. unitStr .. " ", current, project), color = color } + return { label .. ":", string.format(format .. " / " .. format .. unitStr .. " ", current, project), color = color } end -- prepare a table for output and at the same time check the parameters function ShipSummary:prepareAndValidateParamList() local def = ShipDef[ShipType.value] - local usedSlots = ShipEquip.usedSlots local freeCargo = def.cargo self.cargo.valid = true self.equip.valid = true local eq_n_cargo = { valid = true } freeCargo = math.max(freeCargo, 0) local paramList = { - rowWithAlert(eq_n_cargo, lui.CAPACITY, ShipCargo.mass + ShipEquip.mass, def.equipCapacity, greater, 't'), + rowWithAlert(self.equip, lui.CAPACITY, ShipEquip.volume, def.equipCapacity, greater, lc.UNIT_CUBIC_METERS, "%.1f"), rowWithAlert(self.cargo, lui.CARGO_SPACE, ShipCargo.mass, freeCargo, greater, 't'), - --rowWithAlert(self.equip, lui.FRONT_WEAPON, usedSlots.laser_front, def.equipSlotCapacity.laser_front, greater), - --rowWithAlert(self.equip, lui.REAR_WEAPON, usedSlots.laser_rear, def.equipSlotCapacity.laser_rear, greater), - --rowWithAlert(self.equip, lui.CABINS, usedSlots.cabin, def.equipSlotCapacity.cabin, greater), rowWithAlert(self.equip, lui.CREW_CABINS, #Crew.value + 1, def.maxCrew, greater), - --rowWithAlert(self.equip, lui.MISSILE_MOUNTS, usedSlots.missile, def.equipSlotCapacity.missile, greater), - --rowWithAlert(self.equip, lui.SCOOP_MOUNTS, usedSlots.scoop, def.equipSlotCapacity.scoop, greater), } self.cargo.valid = self.cargo.valid and eq_n_cargo.valid self.equip.valid = self.equip.valid and eq_n_cargo.valid @@ -964,23 +1845,26 @@ function ShipSummary:draw() local paramList = self:prepareAndValidateParamList() - ui.child("param_table1", Vector2(self.layout.width, 0), function() + ui.child("param_table1", Vector2(self.layout.width, self.layout.table1_height), function() + local p1 = ui.getCursorPos() textTable.drawTable(2, { self.layout.width - self.valueWidth, self.valueWidth }, paramList) + local p2 = ui.getCursorPos() + self.layout.table1_height = p2.y - p1.y end) - ui.sameLine() + ui.text("") local fuel_mass = def.fuelTankMass * ShipFuel.value * 0.01 -- ShipFuel.value in % local mass_with_fuel = def.hullMass + ShipCargo.mass + ShipEquip.mass + fuel_mass -- adapted from ShipType - local power_mul = 1.0 + ShipEquip:getThrusterUpgradeLevel() * 0.1 + local power_mul = 1.0 + math.clamp(ShipEquip.thrusterUpgradeLevel, 0, 3) * 0.1 local fwd_cap = def.linAccelerationCap.FORWARD local fwd_acc = math.min(def.linearThrust.FORWARD * power_mul / mass_with_fuel / 1000, fwd_cap) local up_cap = def.linAccelerationCap.UP local up_acc = math.min(def.linearThrust.UP * power_mul / mass_with_fuel / 1000, up_cap) - local hyperclass = ShipEquip:getHyperDriveClass() + local hyperclass = ShipEquip.hyperDriveClass local remaining_fuel = ShipCargo:countCommodity('hydrogen') -- adapted from HyperDriveType @@ -1001,7 +1885,8 @@ function ShipSummary:draw() end end - ui.child("##param_table2", Vector2(self.layout.width, 0), function() + ui.child("##param_table2", Vector2(self.layout.width, self.layout.table2_height), function() + local p1 = ui.getCursorPos() textTable.drawTable(2, { self.layout.width - self.valueWidth, self.valueWidth }, { { lui.ALL_UP_WEIGHT..":", string.format("%dt", mass_with_fuel ) }, { lui.HYPERSPACE_RANGE..":", string.format("%.1f / %.1f " .. lui.LY, hyper_range, range_max) }, @@ -1010,8 +1895,9 @@ function ShipSummary:draw() { lui.FORWARD_ACCEL..":", string.format("%.1f / %.1f g", fwd_acc / 9.81, fwd_cap / 9.81) }, { lui.UP_ACCEL..":", string.format("%.1f / %.1f g", up_acc / 9.81, up_cap / 9.81) }, }) + local p2 = ui.getCursorPos() + self.layout.table2_height = p2.y - p1.y end) - ui.sameLine(0, 0) end @@ -1019,10 +1905,22 @@ end -- draw tab -- local function draw() - ui.child("ship_and_params", Vector2(layout.leftWidth, Defs.contentRegion.y), function() + ui.child("params_and_cargo", Vector2(layout.leftWidth, Defs.contentRegion.y), function() - ShipType:draw() ui.sameLine() ShipLabel:draw() - ShipName:draw() ui.sameLine() ShipFuel:draw() + ShipType:draw() + ShipLabel:draw() + ShipName:draw() + ShipFuel:draw() + ui.text("") + ShipSummary:draw() + ui.text("") + ShipCargo:draw() + + end) + + ui.sameLine() + + ui.child("ship_and_equip", Vector2(layout.rightWidth, Defs.contentRegion.y), function() local top = ui.getCursorPos() ShipModel:draw() @@ -1052,23 +1950,17 @@ local function draw() ui.setCursorPos(pos) end - ShipSummary:draw() - end) - ui.sameLine() - ui.child("cargo_and_equip", Vector2(layout.rightWidth, Defs.contentRegion.y), function() - ShipCargo:draw() - ui.text("") ShipEquip:draw() end) end local function updateLayout() - layout.sectionWidth = math.round(Defs.goodWidth / 5) - layout.leftWidth = math.round(0.6 * Defs.contentRegion.x) + + layout.leftWidth = math.round(0.35 * Defs.contentRegion.x) layout.rightWidth = Defs.contentRegion.x - layout.leftWidth - Defs.gap.x layout.patternDragWidth = ui.calcTextSize("<-- " .. lui.PATTERN .. " 00/00 -->").x - local paramWidth = (layout.leftWidth - Defs.gap.x) * 0.5 + local paramWidth = layout.leftWidth - Defs.gap.x -- link layouts to align the width of labels ShipType.layout = { width = paramWidth } @@ -1079,18 +1971,31 @@ local function updateLayout() ShipLabel.layout = { width = paramWidth } ShipModel.layout = { - width = layout.leftWidth, - height = Defs.contentRegion.y * 0.54 -- just picked it up to fit everything + width = layout.rightWidth, + height = Defs.contentRegion.y * 0.5 + } + ShipSummary.layout = { width = paramWidth, table1_height = 0, table2_height = 0 } + ShipSummary.valueWidth = ui.calcTextSize("--3000t / 3000t--").x -- to update + + ShipEquip.layout = { + width = ShipModel.layout.width, + height = Defs.contentRegion.y - ShipModel.layout.height - Defs.gap.y + } + + ShipEquip.editDialog.layout = { + width = ShipEquip.layout.width, + height = Defs.lineHeight * 18, } - ShipSummary.layout = { width = paramWidth } - ShipSummary.valueWidth = ui.calcTextSize("-3000t / 3000t-").x -- to update end local function updateParams() ShipType:setShipID(ShipType.value) - ShipModel:updateModel() ShipCargo:updateDrawItems() +end + +function ShipType.updated() ShipEquip:update() + ShipModel:updateModel() end return { @@ -1104,5 +2009,5 @@ return { Cargo = ShipCargo, Equip = ShipEquip, Model = ShipModel, - Fuel = ShipFuel + Fuel = ShipFuel, } diff --git a/data/pigui/modules/new-game-window/summary.lua b/data/pigui/modules/new-game-window/summary.lua index a6da0c3b45..6c14664a4e 100644 --- a/data/pigui/modules/new-game-window/summary.lua +++ b/data/pigui/modules/new-game-window/summary.lua @@ -11,7 +11,7 @@ local GameParam = require 'pigui.modules.new-game-window.game-param' local ShipDef = require 'ShipDef' local Lang = require 'Lang' local lui = Lang.GetResource("ui-core") -local leq = Lang.GetResource("equipment-core") +local lc = Lang.GetResource("core") local Summary = {} @@ -86,7 +86,7 @@ function Summary:draw() ui.text("") end Widgets.alignLabel(lui.HYPERDRIVE, layout.shipParam, function() - ui.text(Ship.Equip:getHyperDriveClass() > 0 and lui.YES or lui.NO) + ui.text(Ship.Equip.hyperDriveClass > 0 and lui.YES or lui.NO) end) ui.text("") if #Ship.Equip.summaryList == 0 then @@ -97,10 +97,11 @@ function Summary:draw() for _, eq in ipairs(Ship.Equip.summaryList) do -- eq: { obj, count } - if eq.obj and not eq.obj.capabilities.hyperclass then - local count = eq.count > 1 and " x " .. tostring(eq.count) or "" - ui.text(" - " .. leq[eq.obj.l10n_key] .. count) - end + assert(eq.obj) + local count = eq.count > 1 and " x " .. tostring(eq.count) or "" + local l = Lang.GetResource(eq.obj.l10n_resource) + local localized = l[eq.obj.l10n_key] or eq.obj.id or lc.UNKNOWN + ui.text(" - " .. localized .. count) end ui.text("") diff --git a/data/pigui/modules/new-game-window/widgets.lua b/data/pigui/modules/new-game-window/widgets.lua index c90851e23a..9ab8ad473f 100644 --- a/data/pigui/modules/new-game-window/widgets.lua +++ b/data/pigui/modules/new-game-window/widgets.lua @@ -162,4 +162,22 @@ Widgets.incrementDrag = function(lock, label, value, v_speed, v_min, v_max, form end end +-- return actual height +Widgets.filledHeader = function(label, width) + + local fillHeight = Defs.lineHeight + Defs.gap.y + + local p1 = ui.getCursorScreenPos() + local p2 = Vector2(p1.x + width, p1.y + fillHeight) + ui.addRectFilled(p1, p2, ui.theme.colors.tableHighlight, 0, 0) + + ui.addCursorPos(Defs.gap) + ui.text(label) + -- here we are at the bottom edge of the fill, add another gap + ui.addCursorPos(Vector2(0, Defs.gap.y)) + + -- text height plus three gaps + return fillHeight + Defs.gap.y +end + return Widgets diff --git a/src/lua/LuaGame.cpp b/src/lua/LuaGame.cpp index 19600ff0b1..c34436c669 100644 --- a/src/lua/LuaGame.cpp +++ b/src/lua/LuaGame.cpp @@ -90,7 +90,6 @@ static void onSaveGameStatsJobFinished(std::string_view filename, const Json &ro // if this is a newer saved game, show the embedded info if (rootNode["game_info"].is_object()) { const Json &gameInfo = rootNode["game_info"]; - t.Set("compatible", true); t.Set("system", gameInfo["system"].get()); t.Set("ship", gameInfo["ship"].get()); t.Set("credits", gameInfo["credits"].get()); @@ -115,8 +114,11 @@ static void onSaveGameStatsJobFinished(std::string_view filename, const Json &ro const Json &shipNode = rootNode["space"]["bodies"][rootNode["player"].get() - 1]; t.Set("frame", rootNode["space"]["bodies"][shipNode["body"]["index_for_frame"].get() - 1]["body"]["label"].get()); t.Set("ship", shipNode["model_body"]["model_name"].get()); - t.Set("compatible", false); } + + int saveVersion = rootNode["version"].get(); + t.Set("compatible", saveVersion == SaveGameManager::CurrentSaveVersion()); + } catch (const Json::type_error &) { t.Set("compatible", false); } catch (const Json::out_of_range &) { diff --git a/src/lua/LuaPiGui.cpp b/src/lua/LuaPiGui.cpp index bcac960b1a..1b4610e59d 100644 --- a/src/lua/LuaPiGui.cpp +++ b/src/lua/LuaPiGui.cpp @@ -405,6 +405,29 @@ int l_pigui_check_window_flags(lua_State *l) return 1; } +static LuaFlags imguiFocusedFlagsTable = { + { "None", ImGuiFocusedFlags_None }, + { "ChildWindows", ImGuiFocusedFlags_ChildWindows }, + { "RootWindow", ImGuiFocusedFlags_RootWindow }, + { "AnyWindow", ImGuiFocusedFlags_AnyWindow }, + { "NoPopupHierarchy", ImGuiFocusedFlags_NoPopupHierarchy }, + { "DockHierarchy", ImGuiFocusedFlags_DockHierarchy }, + { "RootAndChildWindows", ImGuiFocusedFlags_RootAndChildWindows } +}; + +void pi_lua_generic_pull(lua_State *l, int index, ImGuiFocusedFlags_ &theflags) +{ + theflags = parse_imgui_flags(l, index, imguiFocusedFlagsTable); +} + +int l_pigui_check_focused_flags(lua_State *l) +{ + luaL_checktype(l, 1, LUA_TTABLE); + ImGuiFocusedFlags_ fl = imguiFocusedFlagsTable.LookupTable(l, 1); + LuaPush(l, fl); + return 1; +} + static LuaFlags imguiHoveredFlagsTable = { { "None", ImGuiHoveredFlags_None }, { "ChildWindows", ImGuiHoveredFlags_ChildWindows }, @@ -990,6 +1013,20 @@ static int l_pigui_get_window_content_size(lua_State *l) return 1; } +static int l_pigui_set_keyboard_focus_here(lua_State *l) +{ + PROFILE_SCOPED() + + if (lua_gettop(l) == 0) { + ImGui::SetKeyboardFocusHere(); + return 0; + } else { + auto offset = LuaPull(l, 1); + ImGui::SetKeyboardFocusHere(offset); + return 0; + } +} + static int l_pigui_set_next_window_pos(lua_State *l) { PROFILE_SCOPED() @@ -1983,6 +2020,13 @@ static int l_pigui_is_item_clicked(lua_State *l) return 1; } +static int l_pigui_is_any_item_active(lua_State *l) +{ + PROFILE_SCOPED() + LuaPush(l, ImGui::IsAnyItemActive()); + return 1; +} + static int l_pigui_is_mouse_released(lua_State *l) { PROFILE_SCOPED() @@ -2788,6 +2832,13 @@ static int l_pigui_is_window_hovered(lua_State *l) return 1; } +static int l_pigui_is_window_focused(lua_State *l) +{ + int flags = LuaPull(l, 1, ImGuiFocusedFlags_None); + LuaPush(l, ImGui::IsWindowFocused(flags)); + return 1; +} + static int l_pigui_begin_tab_bar(lua_State *l) { PROFILE_SCOPED() @@ -3555,6 +3606,7 @@ void LuaObject::RegisterClass() { "AlignTextToFramePadding", l_pigui_align_to_frame_padding }, { "AlignTextToLineHeight", l_pigui_align_to_line_height }, { "GetWindowContentSize", l_pigui_get_window_content_size }, + { "SetKeyboardFocusHere", l_pigui_set_keyboard_focus_here }, { "SetNextWindowPos", l_pigui_set_next_window_pos }, { "SetNextWindowSize", l_pigui_set_next_window_size }, { "SetNextWindowSizeConstraints", l_pigui_set_next_window_size_constraints }, @@ -3597,6 +3649,7 @@ void LuaObject::RegisterClass() { "IsItemHovered", l_pigui_is_item_hovered }, { "IsItemActive", l_pigui_is_item_active }, { "IsItemClicked", l_pigui_is_item_clicked }, + { "IsAnyItemActive", l_pigui_is_any_item_active }, { "Spacing", l_pigui_spacing }, { "Dummy", l_pigui_dummy }, { "NewLine", l_pigui_newline }, @@ -3634,6 +3687,7 @@ void LuaObject::RegisterClass() { "IsMouseDown", l_pigui_is_mouse_down }, { "IsMouseHoveringRect", l_pigui_is_mouse_hovering_rect }, { "IsWindowHovered", l_pigui_is_window_hovered }, + { "IsWindowFocused", l_pigui_is_window_focused }, { "Image", l_pigui_image }, { "pointOnClock", l_pigui_pointOnClock }, { "lineOnClock", l_pigui_lineOnClock }, @@ -3695,6 +3749,7 @@ void LuaObject::RegisterClass() { "TreeNodeFlags", l_pigui_check_tree_node_flags }, { "InputTextFlags", l_pigui_check_input_text_flags }, { "WindowFlags", l_pigui_check_window_flags }, + { "FocusedFlags", l_pigui_check_focused_flags }, { "HoveredFlags", l_pigui_check_hovered_flags }, { "TableFlags", l_pigui_check_table_flags }, { "TableColumnFlags", l_pigui_check_table_column_flags }, @@ -3750,6 +3805,7 @@ void LuaObject::RegisterClass() imguiColTable.Register(l, "ImGuiCol"); imguiStyleVarTable.Register(l, "ImGuiStyleVar"); imguiWindowFlagsTable.Register(l, "ImGuiWindowFlags"); + imguiFocusedFlagsTable.Register(l, "ImGuiFocusedFlags"); imguiHoveredFlagsTable.Register(l, "ImGuiHoveredFlags"); imguiTableFlagsTable.Register(l, "ImGuiTableFlags"); imguiTableColumnFlagsTable.Register(l, "ImGuiTableColumnFlags");